canary-test-cli 6.1.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.
@@ -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)) {