canary-test-cli 6.2.0 → 6.3.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.
@@ -344,6 +344,7 @@ export function migrateCmd(opts, deps) {
344
344
  dryRun,
345
345
  framework: opts.framework || null,
346
346
  overlayPath,
347
+ force: opts.force ?? false,
347
348
  });
348
349
  }
349
350
  catch (e) {
@@ -367,6 +368,7 @@ export function migrateCmd(opts, deps) {
367
368
  status: r.status,
368
369
  note: r.note,
369
370
  })),
371
+ installed_workflows: report.installed_workflows.map((r) => r.to_dict()),
370
372
  }));
371
373
  return;
372
374
  }
@@ -107,6 +107,7 @@ export function createCanaryCommand(depsInit = {}) {
107
107
  .option('-o, --overlay <path>', '[deprecated: use --from] Path to an overlay repo whose .canary/skills/ are deployed.')
108
108
  .option('--apply', 'Write files. Without this flag the command is a dry run.')
109
109
  .option('--check', 'Freshness gate: report drift without writing.')
110
+ .option('--force', 'Overwrite a .github/workflows/ file that differs from the overlay template. Without this flag a difference is only reported -- your CI is never rewritten behind your back.')
110
111
  .option('--json', 'Emit the report as JSON.')
111
112
  .action((opts) => {
112
113
  migrateCmd(opts, deps);
@@ -13,9 +13,11 @@
13
13
  * 2. .canary/company.json -- project-local config
14
14
  * 3. .canary/company.<env>.json -- environment override (CANARY_ENV or explicit)
15
15
  *
16
- * List fields are unioned across sources; scalar fields (dashboard_url,
17
- * dashboard_token_env, notes) are replaced by the highest-priority source that
18
- * sets them.
16
+ * List fields ({@link _LIST_FIELDS}) are unioned across sources; scalar fields
17
+ * ({@link _SCALAR_FIELDS}) are replaced by the highest-priority source that
18
+ * sets a non-empty value. Those two arrays are the single place a new field
19
+ * opts into a merge rule, and both are checked for exhaustiveness at compile
20
+ * time.
19
21
  *
20
22
  * Python->TS nuances:
21
23
  * - Python patches `Path.home()` in its tests to isolate the home tier. There
@@ -118,6 +120,12 @@ const _KNOWN_KEYS = new Set([
118
120
  // warning that it is told anyone adopting an overlay that the single field
119
121
  // driving their adoption does nothing.
120
122
  'canary_shape',
123
+ // #459: repo-relative pointers a generated workflow interpolates (the
124
+ // coverage report the guardian reads; the controllers dir it scopes SUT
125
+ // analysis to). See `validateRepoRelativePath` for why they are validated
126
+ // rather than stored verbatim.
127
+ 'coverage_report_path',
128
+ 'sut_controllers_path',
121
129
  ]);
122
130
  const _HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
123
131
  const _BRAND_TEXT_MAX = 200;
@@ -240,6 +248,50 @@ function validateOtelEndpoint(raw, fieldName, warnings) {
240
248
  }
241
249
  return raw;
242
250
  }
251
+ // A path that is absolute in any flavour git checkouts run under: POSIX
252
+ // (`/x`), UNC / Windows-separator (`\x`), or drive-qualified (`C:x`, `C:/x`).
253
+ // Drive-relative `C:x` is included deliberately -- it is not repo-relative.
254
+ const _ABSOLUTE_PATH_RE = /^(?:[/\\]|[A-Za-z]:)/;
255
+ // Newlines would break out of the scalar these values are interpolated into
256
+ // when a workflow template is generated, so they are refused outright rather
257
+ // than escaped -- no legitimate repo path contains one.
258
+ const _PATH_CONTROL_RE = /[\r\n\t\0]/;
259
+ /**
260
+ * A repo-relative path pointer (#459: `coverage_report_path`,
261
+ * `sut_controllers_path`).
262
+ *
263
+ * These are interpolated into generated GitHub Actions YAML, so validation is
264
+ * a safety boundary, not tidiness: an absolute path aims the generated CI at
265
+ * something outside the checkout, and a `..` segment escapes the repo. Both are
266
+ * dropped with a warning (the module's degrade-never-throw convention); a
267
+ * secret-like value raises so the whole layer is refused, exactly as every
268
+ * other non-notes field does.
269
+ *
270
+ * `..` is rejected as a SUBSTRING, not just as a path component. A component
271
+ * check would have to agree with the separator handling of whatever consumes
272
+ * the value later (shell, Actions expression, node `path`); refusing the two
273
+ * characters outright cannot disagree with anything. The cost is rejecting the
274
+ * vanishingly rare legitimate `report..xml`.
275
+ */
276
+ function validateRepoRelativePath(raw, fieldName, warnings) {
277
+ if (typeof raw !== 'string') {
278
+ warnings.push(`${fieldName}: expected string, got ${pyTypeName(raw)} ${EMDASH} skipped`);
279
+ return '';
280
+ }
281
+ const value = raw.trim();
282
+ if (!value)
283
+ return '';
284
+ if (looksLikeSecret(value))
285
+ throw new SecretDetected(fieldName, value);
286
+ if (_ABSOLUTE_PATH_RE.test(value) ||
287
+ value.includes('..') ||
288
+ _PATH_CONTROL_RE.test(value)) {
289
+ warnings.push(`${fieldName}: dropped invalid repo-relative path ${pyRepr(raw)} ` +
290
+ `${EMDASH} must stay inside the repo (no absolute path, no '..')`);
291
+ return '';
292
+ }
293
+ return value;
294
+ }
243
295
  /** Accept #RGB / #RRGGBB (any case); drop anything else with a warning. */
244
296
  function validateHexColor(raw, fieldName, warnings) {
245
297
  if (typeof raw !== 'string' || !raw)
@@ -432,6 +484,14 @@ function parseLayer(data, source) {
432
484
  if (Object.prototype.hasOwnProperty.call(data, 'otel_exporter_endpoint')) {
433
485
  otel_exporter_endpoint = validateOtelEndpoint(data['otel_exporter_endpoint'], 'otel_exporter_endpoint', warns);
434
486
  }
487
+ let coverage_report_path = '';
488
+ if (Object.prototype.hasOwnProperty.call(data, 'coverage_report_path')) {
489
+ coverage_report_path = validateRepoRelativePath(data['coverage_report_path'], 'coverage_report_path', warns);
490
+ }
491
+ let sut_controllers_path = '';
492
+ if (Object.prototype.hasOwnProperty.call(data, 'sut_controllers_path')) {
493
+ sut_controllers_path = validateRepoRelativePath(data['sut_controllers_path'], 'sut_controllers_path', warns);
494
+ }
435
495
  let notes = '';
436
496
  if (Object.prototype.hasOwnProperty.call(data, 'notes')) {
437
497
  const rawNotes = data['notes'];
@@ -455,6 +515,8 @@ function parseLayer(data, source) {
455
515
  dashboard_url,
456
516
  dashboard_token_env,
457
517
  otel_exporter_endpoint,
518
+ coverage_report_path,
519
+ sut_controllers_path,
458
520
  notes,
459
521
  brand,
460
522
  warnings: warns,
@@ -509,51 +571,66 @@ function union(a, b) {
509
571
  }
510
572
  return out;
511
573
  }
574
+ const _LIST_FIELDS = [
575
+ 'confluence_spaces',
576
+ 'jira_projects',
577
+ 'internal_doc_urls',
578
+ 'internal_domains',
579
+ 'mcp_servers',
580
+ 'claude_code_skills',
581
+ ];
582
+ const _SCALAR_FIELDS = [
583
+ 'dashboard_url',
584
+ 'dashboard_token_env',
585
+ 'otel_exporter_endpoint',
586
+ 'coverage_report_path',
587
+ 'sut_controllers_path',
588
+ 'notes',
589
+ ];
590
+ // Compile-time exhaustiveness: adding a field to `Layer`/`MergedFields` without
591
+ // adding it to the matching array above fails the build here (the assertion
592
+ // type collapses to `never`) rather than silently dropping the field at merge.
593
+ const _LIST_FIELDS_EXHAUSTIVE = true;
594
+ const _SCALAR_FIELDS_EXHAUSTIVE = true;
595
+ void _LIST_FIELDS_EXHAUSTIVE;
596
+ void _SCALAR_FIELDS_EXHAUSTIVE;
597
+ function mergeListFields(layers) {
598
+ const out = {};
599
+ for (const field of _LIST_FIELDS) {
600
+ let merged = [];
601
+ for (const layer of layers)
602
+ merged = union(merged, layer[field]);
603
+ out[field] = merged;
604
+ }
605
+ return out;
606
+ }
607
+ function mergeScalarFields(layers) {
608
+ const out = {};
609
+ for (const field of _SCALAR_FIELDS) {
610
+ let merged = '';
611
+ // Highest-priority non-empty wins. A layer whose value was DROPPED as
612
+ // invalid contributes '' and therefore leaves the lower layer's valid value
613
+ // standing (degrade, never blank out) -- load-bearing, and pinned by tests.
614
+ for (const layer of layers)
615
+ if (layer[field])
616
+ merged = layer[field];
617
+ out[field] = merged;
618
+ }
619
+ return out;
620
+ }
512
621
  function mergeLayers(layers) {
513
- let confluence_spaces = [];
514
- let jira_projects = [];
515
- let internal_doc_urls = [];
516
- let internal_domains = [];
517
- let mcp_servers = [];
518
- let claude_code_skills = [];
519
- let dashboard_url = '';
520
- let dashboard_token_env = '';
521
- let otel_exporter_endpoint = '';
522
- let notes = '';
523
- const warns = [];
622
+ const warnings = [];
524
623
  const sources = [];
525
624
  for (const layer of layers) {
526
- confluence_spaces = union(confluence_spaces, layer.confluence_spaces);
527
- jira_projects = union(jira_projects, layer.jira_projects);
528
- internal_doc_urls = union(internal_doc_urls, layer.internal_doc_urls);
529
- internal_domains = union(internal_domains, layer.internal_domains);
530
- mcp_servers = union(mcp_servers, layer.mcp_servers);
531
- claude_code_skills = union(claude_code_skills, layer.claude_code_skills);
532
- if (layer.dashboard_url)
533
- dashboard_url = layer.dashboard_url;
534
- if (layer.dashboard_token_env)
535
- dashboard_token_env = layer.dashboard_token_env;
536
- if (layer.otel_exporter_endpoint)
537
- otel_exporter_endpoint = layer.otel_exporter_endpoint;
538
- if (layer.notes)
539
- notes = layer.notes;
540
- warns.push(...layer.warnings);
625
+ warnings.push(...layer.warnings);
541
626
  if (layer.source)
542
627
  sources.push(layer.source);
543
628
  }
544
629
  return {
545
- confluence_spaces,
546
- jira_projects,
547
- internal_doc_urls,
548
- internal_domains,
549
- mcp_servers,
550
- claude_code_skills,
551
- dashboard_url,
552
- dashboard_token_env,
553
- otel_exporter_endpoint,
554
- notes,
630
+ ...mergeListFields(layers),
631
+ ...mergeScalarFields(layers),
555
632
  brand: mergeBrand(layers),
556
- warnings: warns,
633
+ warnings,
557
634
  sources,
558
635
  };
559
636
  }
@@ -585,6 +662,10 @@ export class CompanyKnowledge {
585
662
  dashboard_url;
586
663
  dashboard_token_env;
587
664
  otel_exporter_endpoint;
665
+ /** Repo-relative path to the coverage report a generated workflow reads. */
666
+ coverage_report_path;
667
+ /** Repo-relative path to the SUT controllers dir analysis is scoped to. */
668
+ sut_controllers_path;
588
669
  notes;
589
670
  brand;
590
671
  warnings;
@@ -600,6 +681,8 @@ export class CompanyKnowledge {
600
681
  this.dashboard_url = init.dashboard_url ?? '';
601
682
  this.dashboard_token_env = init.dashboard_token_env ?? '';
602
683
  this.otel_exporter_endpoint = init.otel_exporter_endpoint ?? '';
684
+ this.coverage_report_path = init.coverage_report_path ?? '';
685
+ this.sut_controllers_path = init.sut_controllers_path ?? '';
603
686
  this.notes = init.notes ?? '';
604
687
  this.brand = init.brand ?? new Brand();
605
688
  this.warnings = init.warnings ?? [];
@@ -733,6 +816,8 @@ export class CompanyKnowledge {
733
816
  dashboard_url: this.dashboard_url,
734
817
  dashboard_token_env: this.dashboard_token_env,
735
818
  otel_exporter_endpoint: this.otel_exporter_endpoint,
819
+ coverage_report_path: this.coverage_report_path,
820
+ sut_controllers_path: this.sut_controllers_path,
736
821
  notes: this.notes,
737
822
  brand: this.brand.toDict(),
738
823
  sources: this.sources,
@@ -39,7 +39,7 @@
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';
@@ -147,34 +147,127 @@ function collectRelFiles(dir) {
147
147
  walk(dir, '');
148
148
  return out;
149
149
  }
150
+ /** An object-valued manifest section, or `{}` when absent/malformed. */
151
+ function manifestSection(data, key) {
152
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
153
+ return {};
154
+ }
155
+ const section = data[key];
156
+ if (section === null ||
157
+ typeof section !== 'object' ||
158
+ Array.isArray(section)) {
159
+ return {};
160
+ }
161
+ return section;
162
+ }
150
163
  /**
151
- * Return `{dirName: {name, hash}}` from the manifest, or `{}` when absent or
152
- * unreadable (provenance is best-effort).
164
+ * Read the whole manifest document (skills + workflows). Both sections default
165
+ * to `{}` when the file is absent, unreadable, or malformed -- provenance is
166
+ * best-effort and never blocks a deploy.
153
167
  */
154
- function readDeployManifest(targetSkillsDir) {
168
+ function readManifestDoc(targetSkillsDir) {
155
169
  const manifestPath = join(targetSkillsDir, DEPLOY_MANIFEST_NAME);
156
170
  let data;
157
171
  try {
158
172
  data = JSON.parse(readFileSync(manifestPath, 'utf-8'));
159
173
  }
160
174
  catch {
161
- return {};
175
+ return { skills: {}, workflows: {} };
162
176
  }
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;
177
+ return {
178
+ skills: manifestSection(data, 'skills'),
179
+ workflows: manifestSection(data, 'workflows'),
180
+ };
170
181
  }
171
- function writeDeployManifest(targetSkillsDir, skills) {
182
+ /**
183
+ * Write the manifest. Read-modify-write of the whole document, because the
184
+ * skill-deploy phase and the workflow-install phase each own one section and
185
+ * both write this one file. `workflows` is omitted entirely when empty so a
186
+ * skills-only manifest keeps its historical bytes.
187
+ */
188
+ function writeManifestDoc(targetSkillsDir, doc) {
172
189
  const manifestPath = join(targetSkillsDir, DEPLOY_MANIFEST_NAME);
173
190
  mkdirSync(targetSkillsDir, { recursive: true });
174
- const body = ensureAscii(JSON.stringify({ schemaVersion: 1, skills }, null, 2)) + '\n';
191
+ const payload = {
192
+ schemaVersion: 1,
193
+ skills: doc.skills,
194
+ };
195
+ if (Object.keys(doc.workflows).length > 0)
196
+ payload['workflows'] = doc.workflows;
197
+ const body = ensureAscii(JSON.stringify(payload, null, 2)) + '\n';
175
198
  writeFileSync(manifestPath, body, 'utf-8');
176
199
  }
177
200
  // ---------------------------------------------------------------------------
201
+ // Workflow templates (#459)
202
+ // ---------------------------------------------------------------------------
203
+ /** Frontmatter key: templates this skill installs into `.github/workflows/`. */
204
+ const WORKFLOW_DECL_KEY = 'install_workflows';
205
+ /** Frontmatter key: the version stamped into the deploy manifest. */
206
+ const WORKFLOW_VERSION_KEY = 'workflow_template_version';
207
+ /** Default when a skill declares templates but no explicit version. */
208
+ const WORKFLOW_DEFAULT_VERSION = '1';
209
+ /**
210
+ * An optional `<shape>:` prefix on a declared entry, e.g.
211
+ * `api:templates/guardian-api.yml`. Two or more leading characters are required
212
+ * so a Windows drive letter (`C:\...`) can never be mistaken for a shape -- such
213
+ * a path is refused by {@link resolveTemplatePath} instead.
214
+ */
215
+ const _WORKFLOW_ENTRY_RE = /^([A-Za-z][A-Za-z0-9_-]+):(.+)$/;
216
+ function sha256(bytes) {
217
+ return createHash('sha256').update(bytes).digest('hex');
218
+ }
219
+ /** File bytes, or null when unreadable (e.g. the path is a directory). */
220
+ function readBytesOrNull(path) {
221
+ try {
222
+ return readFileSync(path);
223
+ }
224
+ catch {
225
+ return null;
226
+ }
227
+ }
228
+ /**
229
+ * The workflow declaration in a skill's SKILL.md frontmatter.
230
+ *
231
+ * Parsed here rather than on {@link SkillInfo} because these fields are
232
+ * migrator-specific: the registry's job is discovery, and every other consumer
233
+ * of `SkillInfo` would carry two fields it never reads.
234
+ */
235
+ function readWorkflowDeclaration(skillMd) {
236
+ const text = readTextOrNull(skillMd);
237
+ if (text === null)
238
+ return { entries: [], version: WORKFLOW_DEFAULT_VERSION };
239
+ const fm = SkillRegistry.parseFrontmatter(text);
240
+ const entries = SkillRegistry.parseStrList(fm, WORKFLOW_DECL_KEY);
241
+ const rawVersion = fm[WORKFLOW_VERSION_KEY];
242
+ const version = typeof rawVersion === 'string' && rawVersion.trim()
243
+ ? rawVersion.trim()
244
+ : WORKFLOW_DEFAULT_VERSION;
245
+ return { entries, version };
246
+ }
247
+ /** Split `[<shape>:]<relative-path>` into its shape filter and path. */
248
+ function parseWorkflowEntry(entry) {
249
+ const m = _WORKFLOW_ENTRY_RE.exec(entry.trim());
250
+ if (m === null)
251
+ return [null, entry.trim()];
252
+ return [m[1].toLowerCase(), m[2].trim()];
253
+ }
254
+ /**
255
+ * Resolve a declared template path inside *skillDir*, or null when it escapes.
256
+ *
257
+ * An overlay is third-party content, so a declared path is untrusted input: an
258
+ * absolute path or a `..` climb would let an overlay copy an arbitrary file
259
+ * from the machine running `migrate` into the consumer's CI directory.
260
+ */
261
+ function resolveTemplatePath(skillDir, rel) {
262
+ if (!rel || isAbsolute(rel) || /^[A-Za-z]:/.test(rel))
263
+ return null;
264
+ const base = resolve(skillDir);
265
+ const full = resolve(base, rel);
266
+ if (full !== base && !full.startsWith(base + sep))
267
+ return null;
268
+ return full;
269
+ }
270
+ // ---------------------------------------------------------------------------
178
271
  // Constants / probes
179
272
  // ---------------------------------------------------------------------------
180
273
  /**
@@ -416,6 +509,43 @@ export class SkillDeployResult {
416
509
  this.note = note;
417
510
  }
418
511
  }
512
+ /**
513
+ * The outcome of installing one declared workflow template (#459).
514
+ *
515
+ * `status` is one of:
516
+ * - `installed` -- the target had no such workflow; it was written
517
+ * - `skipped` -- byte-identical to the template; nothing to do
518
+ * - `outdated` -- differs, but is unmodified since canary installed it, so
519
+ * the overlay simply moved on (a template fix is waiting)
520
+ * - `conflict` -- differs and was edited locally / has unknown provenance
521
+ * - `updated` -- differed and `--force` replaced it
522
+ * - `dry_run` -- what an `--apply` run would have done
523
+ * - `missing` -- the overlay declares a template it does not ship
524
+ * - `invalid` -- the declared path escapes the skill directory (refused)
525
+ *
526
+ * `outdated` and `conflict` are REPORTS. Neither ever writes.
527
+ */
528
+ export class WorkflowInstallResult {
529
+ /** File name under `.github/workflows/`. */
530
+ workflow;
531
+ skill_name;
532
+ status;
533
+ detail;
534
+ constructor(workflow, skill_name, status, detail = '') {
535
+ this.workflow = workflow;
536
+ this.skill_name = skill_name;
537
+ this.status = status;
538
+ this.detail = detail;
539
+ }
540
+ to_dict() {
541
+ return {
542
+ workflow: this.workflow,
543
+ skill_name: this.skill_name,
544
+ status: this.status,
545
+ detail: this.detail,
546
+ };
547
+ }
548
+ }
419
549
  export class SkillFreshnessResult {
420
550
  skill_name;
421
551
  dir_name;
@@ -433,10 +563,21 @@ export class FreshnessReport {
433
563
  shape;
434
564
  overlay_path;
435
565
  results;
436
- constructor(shape, overlay_path = null, results = []) {
566
+ /**
567
+ * What a workflow install WOULD do (#459) -- informational only.
568
+ *
569
+ * Deliberately excluded from `has_drift` / `has_local_edits` / `exit_code`:
570
+ * the freshness gate speaks for overlay-owned skills, and a consumer's
571
+ * `.github/workflows/` is not overlay-owned. Failing CI because someone
572
+ * hand-tuned their own workflow, or has not adopted one at all, would be
573
+ * nagging about something canary has no claim over.
574
+ */
575
+ workflows;
576
+ constructor(shape, overlay_path = null, results = [], workflows = []) {
437
577
  this.shape = shape;
438
578
  this.overlay_path = overlay_path;
439
579
  this.results = results;
580
+ this.workflows = workflows;
440
581
  }
441
582
  get stale() {
442
583
  return this.results.filter((r) => r.status === 'stale' || r.status === 'missing');
@@ -475,6 +616,7 @@ export class FreshnessReport {
475
616
  status: r.status,
476
617
  detail: r.detail,
477
618
  })),
619
+ workflows: this.workflows.map((r) => r.to_dict()),
478
620
  };
479
621
  }
480
622
  to_markdown() {
@@ -486,6 +628,7 @@ export class FreshnessReport {
486
628
  ];
487
629
  if (this.results.length === 0) {
488
630
  lines.push("_No overlay skills match this project's shape._", '');
631
+ lines.push(...workflowMarkdown(this.workflows, false));
489
632
  return lines.join('\n');
490
633
  }
491
634
  if (this.in_sync) {
@@ -514,9 +657,37 @@ export class FreshnessReport {
514
657
  'edits above (revert them, or upstream them into the overlay) ' +
515
658
  'before the freshness gate can pass.', '');
516
659
  }
660
+ lines.push(...workflowMarkdown(this.workflows, false));
517
661
  return lines.join('\n');
518
662
  }
519
663
  }
664
+ /**
665
+ * Render the workflow-install section shared by both reports.
666
+ *
667
+ * The closing note is not decoration: it is the only place a consumer is told
668
+ * that a reported difference will never be applied behind their back, and how
669
+ * to opt in when they do want the overlay's version.
670
+ */
671
+ function workflowMarkdown(results, dryRun) {
672
+ if (results.length === 0)
673
+ return [];
674
+ const heading = dryRun
675
+ ? '## Workflows (would install into `.github/workflows/`)'
676
+ : '## Workflows (`.github/workflows/`)';
677
+ const lines = [heading, ''];
678
+ // Every result carries a detail; the status alone would not tell a consumer
679
+ // which file was touched or what to do next.
680
+ for (const r of results) {
681
+ lines.push(`- \`${r.workflow}\` ${EMDASH} ${r.detail}`);
682
+ }
683
+ lines.push('');
684
+ if (results.some((r) => r.status === 'conflict' || r.status === 'outdated')) {
685
+ lines.push(`${WARN} Your CI is yours: canary never overwrites a workflow that ` +
686
+ 'differs from the template. Re-run with `--force` to take the ' +
687
+ "overlay's version.", '');
688
+ }
689
+ return lines;
690
+ }
520
691
  export class MigrationReport {
521
692
  framework;
522
693
  shape;
@@ -530,6 +701,7 @@ export class MigrationReport {
530
701
  would_create;
531
702
  manual_followups;
532
703
  deployed_skills;
704
+ installed_workflows;
533
705
  config_warnings;
534
706
  constructor(init) {
535
707
  this.framework = init.framework;
@@ -544,6 +716,7 @@ export class MigrationReport {
544
716
  this.would_create = init.would_create ?? [];
545
717
  this.manual_followups = init.manual_followups ?? [];
546
718
  this.deployed_skills = init.deployed_skills ?? [];
719
+ this.installed_workflows = init.installed_workflows ?? [];
547
720
  this.config_warnings = init.config_warnings ?? [];
548
721
  }
549
722
  to_markdown() {
@@ -642,6 +815,7 @@ export class MigrationReport {
642
815
  }
643
816
  lines.push('');
644
817
  }
818
+ lines.push(...workflowMarkdown(this.installed_workflows, this.dry_run));
645
819
  if (this.manual_followups.length > 0) {
646
820
  lines.push('## Manual Follow-ups Required', '');
647
821
  for (const item of this.manual_followups)
@@ -719,6 +893,7 @@ export class HarnessMigrator {
719
893
  const dryRun = options.dryRun ?? true;
720
894
  const framework = options.framework ?? null;
721
895
  const overlayPath = options.overlayPath ?? null;
896
+ const force = options.force ?? false;
722
897
  const ctx = this.detect(projectRoot);
723
898
  if (!ctx.is_harness_project) {
724
899
  if (ctx.not_test_project_reason)
@@ -741,7 +916,9 @@ export class HarnessMigrator {
741
916
  candidates: KNOWN_FRAMEWORKS,
742
917
  overrideHint: '`canary migrate --framework <name>`',
743
918
  }));
744
- // Issue #295 point 3: a detection miss must not block skill deployment.
919
+ // Issue #295 point 3: a detection miss must not block skill deployment --
920
+ // nor, for the same reason, the workflow install (#459). The guardian
921
+ // workflow is exactly what an unrecognised repo most needs.
745
922
  const deployed = this.deploySkills(shape, overlayPath, projectRoot, dryRun);
746
923
  return new MigrationReport({
747
924
  framework: 'unknown',
@@ -752,6 +929,7 @@ export class HarnessMigrator {
752
929
  manual_followups: followups,
753
930
  config_warnings: ctx.config_warnings,
754
931
  deployed_skills: deployed,
932
+ installed_workflows: this.installWorkflows(shape, overlayPath, projectRoot, dryRun, force),
755
933
  });
756
934
  }
757
935
  // A framework canary knows but cannot scaffold gets no config boilerplate.
@@ -768,6 +946,9 @@ export class HarnessMigrator {
768
946
  const preserved = this.findExistingTests(projectRoot);
769
947
  const scaffolder = new Scaffolder();
770
948
  const deployed = this.deploySkills(shape, overlayPath, projectRoot, dryRun);
949
+ // Post-copy install phase: the template bytes already landed under
950
+ // .canary/skills/ with the skill; this puts them where Actions looks.
951
+ const installedWorkflows = this.installWorkflows(shape, overlayPath, projectRoot, dryRun, force);
771
952
  if (dryRun) {
772
953
  const tmpl = TEMPLATES[effectiveFramework];
773
954
  const files = tmpl?.files ?? {};
@@ -788,6 +969,7 @@ export class HarnessMigrator {
788
969
  preserved_files: preserved,
789
970
  manual_followups: followups,
790
971
  deployed_skills: deployed,
972
+ installed_workflows: installedWorkflows,
791
973
  config_warnings: ctx.config_warnings,
792
974
  });
793
975
  }
@@ -804,6 +986,7 @@ export class HarnessMigrator {
804
986
  preserved_files: preserved,
805
987
  manual_followups: followups,
806
988
  deployed_skills: deployed,
989
+ installed_workflows: installedWorkflows,
807
990
  config_warnings: ctx.config_warnings,
808
991
  });
809
992
  }
@@ -873,7 +1056,8 @@ export class HarnessMigrator {
873
1056
  const results = [];
874
1057
  const skillsToDeploy = this.collectOverlaySkills(shape, overlayPath);
875
1058
  const targetSkillsDir = join(targetRoot, '.canary', 'skills');
876
- const manifest = readDeployManifest(targetSkillsDir);
1059
+ const doc = readManifestDoc(targetSkillsDir);
1060
+ const manifest = doc.skills;
877
1061
  let manifestDirty = false;
878
1062
  for (const [info, skillDir] of skillsToDeploy) {
879
1063
  const dirName = basename(skillDir);
@@ -918,7 +1102,117 @@ export class HarnessMigrator {
918
1102
  results.push(new SkillDeployResult(info.name, 'copied'));
919
1103
  }
920
1104
  if (manifestDirty && !dryRun)
921
- writeDeployManifest(targetSkillsDir, manifest);
1105
+ writeManifestDoc(targetSkillsDir, doc);
1106
+ return results;
1107
+ }
1108
+ /**
1109
+ * Install the workflow templates the shape-matching overlay skills declare
1110
+ * into the target's `.github/workflows/` (#459).
1111
+ *
1112
+ * This runs AFTER the skill copy and is a distinct phase, not an extension of
1113
+ * it: the bytes already arrive (whole skill dirs are copied, templates
1114
+ * included) -- what was missing is putting them where GitHub Actions looks.
1115
+ *
1116
+ * **Ownership deliberately differs from `deploySkills`.** Deployed skills are
1117
+ * owned one-way by the overlay (#334); a consumer's CI is NOT. Absent ->
1118
+ * write. Byte-identical -> no-op. Different -> report and leave alone, always,
1119
+ * whatever the provenance. `force` is the deliberate escape hatch. Clobbering
1120
+ * a hand-tuned workflow -- or nagging that it is "stale" via an exit code --
1121
+ * would be a worse failure than the partial adoption this fixes.
1122
+ *
1123
+ * Shape selection reuses the same resolved `canary_shape` that drives
1124
+ * `deploy_to` matching: skills are gated by {@link collectOverlaySkills}, and
1125
+ * an entry may additionally carry a `<shape>:` prefix to pick a variant.
1126
+ */
1127
+ installWorkflows(shape, overlayPath, targetRoot, dryRun, force = false) {
1128
+ const results = [];
1129
+ const skills = this.collectOverlaySkills(shape, overlayPath);
1130
+ const targetSkillsDir = join(targetRoot, '.canary', 'skills');
1131
+ const doc = readManifestDoc(targetSkillsDir);
1132
+ const workflowsDir = join(targetRoot, '.github', 'workflows');
1133
+ let manifestDirty = false;
1134
+ for (const [info, skillDir] of skills) {
1135
+ const { entries, version } = readWorkflowDeclaration(info.path);
1136
+ for (const entry of entries) {
1137
+ const [wantShape, rel] = parseWorkflowEntry(entry);
1138
+ if (wantShape !== null && wantShape !== shape && wantShape !== 'all') {
1139
+ continue;
1140
+ }
1141
+ const src = resolveTemplatePath(skillDir, rel);
1142
+ if (src === null) {
1143
+ results.push(new WorkflowInstallResult(basename(rel), info.name, 'invalid', `declared template '${rel}' resolves outside the skill directory ` +
1144
+ `${EMDASH} refused`));
1145
+ continue;
1146
+ }
1147
+ if (!isFile(src)) {
1148
+ results.push(new WorkflowInstallResult(basename(rel), info.name, 'missing', `the overlay declares '${rel}' but does not ship it`));
1149
+ continue;
1150
+ }
1151
+ const name = basename(src);
1152
+ const dest = join(workflowsDir, name);
1153
+ const templateBytes = readFileSync(src);
1154
+ const templateHash = sha256(templateBytes);
1155
+ const record = () => {
1156
+ doc.workflows[name] = {
1157
+ skill: info.name,
1158
+ template: rel,
1159
+ version,
1160
+ hash: templateHash,
1161
+ };
1162
+ manifestDirty = true;
1163
+ };
1164
+ const install = () => {
1165
+ mkdirSync(workflowsDir, { recursive: true });
1166
+ writeFileSync(dest, templateBytes);
1167
+ record();
1168
+ };
1169
+ const push = (status, detail) => {
1170
+ results.push(new WorkflowInstallResult(name, info.name, status, detail));
1171
+ };
1172
+ if (!existsSync(dest)) {
1173
+ if (dryRun) {
1174
+ push('dry_run', `would install .github/workflows/${name} (v${version})`);
1175
+ continue;
1176
+ }
1177
+ install();
1178
+ push('installed', `installed .github/workflows/${name} (v${version})`);
1179
+ continue;
1180
+ }
1181
+ const installedBytes = readBytesOrNull(dest);
1182
+ if (installedBytes !== null && installedBytes.equals(templateBytes)) {
1183
+ // Back-fill provenance for a hand-placed but identical file so a later
1184
+ // template fix can be reported as `outdated` rather than `conflict`.
1185
+ if (doc.workflows[name]?.hash !== templateHash)
1186
+ record();
1187
+ push('skipped', `.github/workflows/${name} already current`);
1188
+ continue;
1189
+ }
1190
+ if (force) {
1191
+ if (dryRun) {
1192
+ push('dry_run', `would overwrite .github/workflows/${name} with v${version} (--force)`);
1193
+ continue;
1194
+ }
1195
+ install();
1196
+ push('updated', `overwrote .github/workflows/${name} with v${version} (--force)`);
1197
+ continue;
1198
+ }
1199
+ const recorded = doc.workflows[name];
1200
+ const untouched = installedBytes !== null &&
1201
+ recorded !== undefined &&
1202
+ recorded.hash === sha256(installedBytes);
1203
+ if (untouched) {
1204
+ push('outdated', `.github/workflows/${name} is at v${recorded.version}, the overlay ` +
1205
+ `ships v${version} ${EMDASH} unmodified since install, so re-run ` +
1206
+ 'with --force to take the update');
1207
+ continue;
1208
+ }
1209
+ push('conflict', `.github/workflows/${name} differs from the overlay template and was ` +
1210
+ `left untouched ${EMDASH} your CI is yours; re-run with --force to ` +
1211
+ 'replace it');
1212
+ }
1213
+ }
1214
+ if (manifestDirty && !dryRun)
1215
+ writeManifestDoc(targetSkillsDir, doc);
922
1216
  return results;
923
1217
  }
924
1218
  /**
@@ -937,7 +1231,7 @@ export class HarnessMigrator {
937
1231
  const shape = ctx.detected_shape;
938
1232
  const skills = this.collectOverlaySkills(shape, overlayPath);
939
1233
  const targetSkillsDir = join(projectRoot, '.canary', 'skills');
940
- const manifest = readDeployManifest(targetSkillsDir);
1234
+ const manifest = readManifestDoc(targetSkillsDir).skills;
941
1235
  const results = [];
942
1236
  for (const [info, skillDir] of skills) {
943
1237
  const dirName = basename(skillDir);
@@ -960,7 +1254,10 @@ export class HarnessMigrator {
960
1254
  results.push(new SkillFreshnessResult(info.name, dirName, 'local_edit', 'deployed skill has local edits; refusing to overwrite'));
961
1255
  }
962
1256
  }
963
- return new FreshnessReport(shape, overlayPath !== null ? String(overlayPath) : null, results);
1257
+ return new FreshnessReport(shape, overlayPath !== null ? String(overlayPath) : null, results,
1258
+ // dryRun = true: `--check` reports what an install WOULD do and never
1259
+ // writes. Informational only -- see FreshnessReport.workflows.
1260
+ this.installWorkflows(shape, overlayPath, projectRoot, true, false));
964
1261
  }
965
1262
  detectFramework(root, config) {
966
1263
  // 0. Explicit override in .canary/company.json ("canary_shape" field).
@@ -181,7 +181,11 @@ export class AuthoringContext {
181
181
  effective_tier; // from resolveTier (2 == can author)
182
182
  is_fork; // reuse Phase-2 fork/403 detection -- (b)
183
183
  repo_root; // collision + sentinel base
184
- authored_sentinel_present; // loop-guard -- (a)
184
+ // loop-guard -- (a). True only when a sentinel stamped at the CURRENT HEAD
185
+ // exists (#456): the caller resolves the stamp, and every unverifiable state
186
+ // (missing/unreadable/malformed sentinel, unresolvable HEAD) passes `false`
187
+ // so authoring fails OPEN.
188
+ authored_sentinel_present;
185
189
  constructor(author_tests_optin, effective_tier, init = {}) {
186
190
  this.author_tests_optin = author_tests_optin;
187
191
  this.effective_tier = effective_tier;
@@ -256,7 +260,8 @@ function authoringSkipReason(gap, ctx) {
256
260
  return `fork: read-only ${EM_DASH} guardian never writes on a fork PR`;
257
261
  }
258
262
  if (ctx.authored_sentinel_present) {
259
- return `loop-guard: guardian tests already authored this run ${EM_DASH} not re-authoring`;
263
+ return (`loop-guard: guardian tests already authored at this HEAD ${EM_DASH} ` +
264
+ `review and commit them to re-enable authoring`);
260
265
  }
261
266
  const target = joinPosix(ctx.repo_root, targetTestPath(gap));
262
267
  if (existsSync(target)) {
@@ -258,6 +258,61 @@ const AUTHORED_SENTINEL_NAME = 'canary-guardian-authored';
258
258
  function authoredSentinelPath(deps, root) {
259
259
  return join(gitDir(deps, root), AUTHORED_SENTINEL_NAME);
260
260
  }
261
+ // The sentinel's FIRST line stamps the HEAD the guardian authored at:
262
+ // `HEAD <sha>`. Every line after it is one authored path. Anchored at the start
263
+ // of the body and hex-only, with a trailing `[ \t\r]*` so a CRLF-written file
264
+ // still parses -- anything else reads as malformed, which fails OPEN.
265
+ const SENTINEL_HEAD_RE = /^HEAD ([0-9a-fA-F]{7,64})[ \t\r]*(?:\n|$)/;
266
+ /**
267
+ * Parse the `HEAD <sha>` stamp off a sentinel body; `null` when malformed.
268
+ *
269
+ * Malformed covers empty, headerless (the pre-#456 paths-only format), and any
270
+ * unparseable first line. Callers MUST treat `null` as "cannot verify" and fail
271
+ * OPEN -- an unreadable sentinel must never wedge authoring off (#456).
272
+ */
273
+ function sentinelHeadStamp(text) {
274
+ const match = SENTINEL_HEAD_RE.exec(text);
275
+ return match === null ? null : match[1].toLowerCase();
276
+ }
277
+ /** Current `HEAD` sha for `root`, or `null` when git/HEAD is unavailable. */
278
+ function headSha(deps, root) {
279
+ const res = deps.runGit(['rev-parse', 'HEAD'], root);
280
+ if (res === null || res.code !== 0)
281
+ return null; // no git / no commits
282
+ return res.stdout.trim().toLowerCase() || null;
283
+ }
284
+ /**
285
+ * Is the loop guard live -- i.e. does a sentinel stamped at the CURRENT `HEAD`
286
+ * exist?
287
+ *
288
+ * This is the surviving half of the stage-and-block-once contract (#456). The
289
+ * component that CLEARED the sentinel on the next commit
290
+ * (`hooks/guardian_precommit.py`) was deleted as dead code in #449, which left
291
+ * `author-plan` fail-closed forever: author once in a clone and Tier-2 authoring
292
+ * never ran again. Stamping HEAD makes the guard self-expiring -- once the human
293
+ * reviews and commits the staged tests, `HEAD` moves, the stamp stops matching,
294
+ * and authoring re-enables itself with no manual step and no hook.
295
+ *
296
+ * Every unverifiable state FAILS OPEN (returns `false`, authoring allowed):
297
+ * missing or unreadable sentinel, a malformed/absent `HEAD` header, or a `HEAD`
298
+ * we cannot resolve. Fail-closed here is exactly the bug being fixed.
299
+ */
300
+ function authoredSentinelActive(deps, root) {
301
+ let body;
302
+ try {
303
+ body = readFileSync(authoredSentinelPath(deps, root), 'utf-8');
304
+ }
305
+ catch {
306
+ return false; // absent or unreadable -> fail open
307
+ }
308
+ const stamp = sentinelHeadStamp(body);
309
+ if (stamp === null)
310
+ return false; // malformed -> fail open
311
+ const head = headSha(deps, root);
312
+ if (head === null)
313
+ return false; // unverifiable -> fail open
314
+ return head === stamp;
315
+ }
261
316
  /**
262
317
  * Return raw unified-diff text from a source.
263
318
  *
@@ -785,7 +840,8 @@ function authorPlanCmd(opts, deps) {
785
840
  const ctx = new AuthoringContext(config.precommit_author_tests, effective, {
786
841
  is_fork: isForkContext(deps.env),
787
842
  repo_root: repoRoot,
788
- authored_sentinel_present: existsSync(authoredSentinelPath(deps, repoRoot)),
843
+ // #456: HEAD-stamped, so the guard expires on the next commit by itself.
844
+ authored_sentinel_present: authoredSentinelActive(deps, repoRoot),
789
845
  });
790
846
  const results = deps.makeAgentTier().author_tests(gaps, ctx);
791
847
  const decision = decideBlock(results);
@@ -799,11 +855,23 @@ function authorPlanCmd(opts, deps) {
799
855
  };
800
856
  deps.out(ensureAscii(JSON.stringify(payload, null, 2)));
801
857
  }
858
+ /**
859
+ * Record the authored paths in the loop-guard sentinel, stamped with the HEAD
860
+ * they were authored at (#456).
861
+ *
862
+ * The `HEAD <sha>` header is what makes the guard self-expiring: `author-plan`
863
+ * honors it only while `HEAD` still matches, so the human's review commit clears
864
+ * it implicitly. When `HEAD` cannot be resolved (a repo with no commits, or no
865
+ * git at all) the header is omitted -- an unstamped sentinel reads as malformed
866
+ * and FAILS OPEN, which is the safe direction.
867
+ */
802
868
  function markAuthoredCmd(opts, deps) {
803
869
  const root = gitToplevel(deps);
804
870
  const sentinel = authoredSentinelPath(deps, root);
805
871
  mkdirSync(dirname(sentinel), { recursive: true });
806
- const body = opts.path.map((p) => `${p}\n`).join('');
872
+ const head = headSha(deps, root);
873
+ const header = head === null ? '' : `HEAD ${head}\n`;
874
+ const body = header + opts.path.map((p) => `${p}\n`).join('');
807
875
  writeFileSync(sentinel, body, 'utf-8');
808
876
  deps.out(`guardian: recorded ${opts.path.length} authored path(s) ${RIGHT_ARROW} ${sentinel}`);
809
877
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canary-test-cli",
3
- "version": "6.2.0",
3
+ "version": "6.3.0",
4
4
  "description": "Canary — AI-powered test automation agent",
5
5
  "license": "MIT",
6
6
  "repository": {