canary-test-cli 7.0.0 → 7.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/agents/skills/README.md +308 -0
  2. package/agents/skills/canary:generate.md +49 -0
  3. package/agents/skills/canary:init.md +37 -0
  4. package/agents/skills/canary:migrate.md +66 -0
  5. package/agents/skills/claude-code/canary-add-framework/SKILL.md +248 -0
  6. package/agents/skills/claude-code/canary-blackhawk/SKILL.md +170 -0
  7. package/agents/skills/claude-code/canary-blackhawk/scripts/cli.mjs +188 -0
  8. package/agents/skills/claude-code/canary-blackhawk/scripts/rules.mjs +120 -0
  9. package/agents/skills/claude-code/canary-blackhawk/scripts/scanner.mjs +244 -0
  10. package/agents/skills/claude-code/canary-blackhawk/scripts/string-literals.mjs +116 -0
  11. package/agents/skills/claude-code/canary-cassandra/SKILL.md +180 -0
  12. package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +268 -0
  13. package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
  14. package/agents/skills/claude-code/canary-ci-ready/SKILL.md +161 -0
  15. package/agents/skills/claude-code/canary-ci-ready/skill.yaml +14 -0
  16. package/agents/skills/claude-code/canary-company-knowledge/SKILL.md +196 -0
  17. package/agents/skills/claude-code/canary-critical-areas/SKILL.md +142 -0
  18. package/agents/skills/claude-code/canary-critical-areas/skill.yaml +16 -0
  19. package/agents/skills/claude-code/canary-edge-case-discovery/SKILL.md +160 -0
  20. package/agents/skills/claude-code/canary-edge-case-discovery/skill.yaml +16 -0
  21. package/agents/skills/claude-code/canary-fail-fast/SKILL.md +75 -0
  22. package/agents/skills/claude-code/canary-fail-fast/scripts/cli.mjs +118 -0
  23. package/agents/skills/claude-code/canary-fail-fast/scripts/digest.mjs +69 -0
  24. package/agents/skills/claude-code/canary-fail-fast/scripts/failures.mjs +60 -0
  25. package/agents/skills/claude-code/canary-fail-fast/scripts/fastfail_check.mjs +43 -0
  26. package/agents/skills/claude-code/canary-fail-fast/scripts/parse.mjs +149 -0
  27. package/agents/skills/claude-code/canary-failure-impact/SKILL.md +153 -0
  28. package/agents/skills/claude-code/canary-failure-impact/skill.yaml +15 -0
  29. package/agents/skills/claude-code/canary-fleet-health/SKILL.md +196 -0
  30. package/agents/skills/claude-code/canary-generate-test/SKILL.md +185 -0
  31. package/agents/skills/claude-code/canary-instrument/SKILL.md +157 -0
  32. package/agents/skills/claude-code/canary-instrument/scripts/cli.mjs +178 -0
  33. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs +96 -0
  34. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/playwright-fixture.ts +44 -0
  35. package/agents/skills/claude-code/canary-instrument/scripts/run_types.mjs +81 -0
  36. package/agents/skills/claude-code/canary-instrument/scripts/span_reader.mjs +187 -0
  37. package/agents/skills/claude-code/canary-katana/SKILL.md +243 -0
  38. package/agents/skills/claude-code/canary-katana/scripts/alarm.mjs +296 -0
  39. package/agents/skills/claude-code/canary-katana/scripts/cli.mjs +247 -0
  40. package/agents/skills/claude-code/canary-katana/scripts/diffscan.mjs +0 -0
  41. package/agents/skills/claude-code/canary-katana/scripts/ledger.mjs +183 -0
  42. package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +129 -0
  43. package/agents/skills/claude-code/canary-pr-guardian/skill.yaml +17 -0
  44. package/agents/skills/claude-code/canary-promote-test/SKILL.md +228 -0
  45. package/agents/skills/claude-code/canary-savant/SKILL.md +233 -0
  46. package/agents/skills/claude-code/canary-savant/scripts/cli.mjs +274 -0
  47. package/agents/skills/claude-code/canary-savant/scripts/restoration.mjs +274 -0
  48. package/agents/skills/claude-code/canary-savant/scripts/rules.mjs +168 -0
  49. package/agents/skills/claude-code/canary-savant/scripts/runner.mjs +572 -0
  50. package/agents/skills/claude-code/canary-savant/scripts/scanner.mjs +374 -0
  51. package/agents/skills/claude-code/canary-savant/scripts/string-literals.mjs +116 -0
  52. package/agents/skills/claude-code/canary-setup-harness/SKILL.md +263 -0
  53. package/agents/skills/claude-code/canary-shadow/SKILL.md +131 -0
  54. package/agents/skills/claude-code/canary-shadow/scripts/cases.example.json +32 -0
  55. package/agents/skills/claude-code/canary-shadow/scripts/cli.mjs +195 -0
  56. package/agents/skills/claude-code/canary-ship/SKILL.md +177 -0
  57. package/agents/skills/claude-code/canary-ship/skill.yaml +16 -0
  58. package/agents/skills/claude-code/canary-strix/SKILL.md +130 -0
  59. package/agents/skills/claude-code/canary-strix/scripts/cli.mjs +255 -0
  60. package/agents/skills/claude-code/canary-strix/scripts/scanner.mjs +252 -0
  61. package/agents/skills/claude-code/canary-strix/scripts/terms.mjs +132 -0
  62. package/agents/skills/claude-code/canary-test-pipeline/SKILL.md +159 -0
  63. package/agents/skills/claude-code/canary-test-pipeline/skill.yaml +19 -0
  64. package/agents/skills/claude-code/canary-test-reporter/SKILL.md +138 -0
  65. package/agents/skills/claude-code/canary-test-reporter/scripts/cli.mjs +98 -0
  66. package/agents/skills/claude-code/canary-test-reporter/scripts/json_report.mjs +58 -0
  67. package/agents/skills/claude-code/canary-test-reporter/scripts/parse.mjs +216 -0
  68. package/agents/skills/claude-code/canary-test-reporter/scripts/render.mjs +114 -0
  69. package/agents/skills/lib/parse-args.mjs +214 -0
  70. package/dist/engine/analysis/cli.js +116 -54
  71. package/dist/engine/analysis/engine.js +34 -16
  72. package/dist/engine/analysis/reports.js +5 -4
  73. package/dist/engine/cli-commands.js +252 -106
  74. package/dist/engine/cli-common.js +15 -24
  75. package/dist/engine/cli.core.js +37 -11
  76. package/dist/engine/cli.js +2 -2
  77. package/dist/engine/company-knowledge-cli.js +2 -2
  78. package/dist/engine/core/adoption.js +408 -0
  79. package/dist/engine/core/framework-probes.js +7 -7
  80. package/dist/engine/core/fs-glob.js +2 -2
  81. package/dist/engine/core/gate-result.js +17 -0
  82. package/dist/engine/core/migrator.js +9 -17
  83. package/dist/engine/core/pattern-matcher.js +23 -5
  84. package/dist/engine/core/persona.js +421 -0
  85. package/dist/engine/core/promotion-verdict.js +261 -0
  86. package/dist/engine/core/quality-scorer.js +15 -2
  87. package/dist/engine/core/reporter.js +1 -9
  88. package/dist/engine/core/skill-dispatch.js +115 -0
  89. package/dist/engine/core/skill-examples.js +392 -0
  90. package/dist/engine/core/skill-registry.js +59 -4
  91. package/dist/engine/core/skill-surfaces.js +307 -0
  92. package/dist/engine/core/static-linter.js +310 -38
  93. package/dist/engine/core/test-files.js +77 -0
  94. package/dist/engine/core/ticket-updater.js +1 -7
  95. package/dist/engine/core/vacuity-scanner.js +726 -0
  96. package/dist/engine/core/workflow-discovery.js +2 -8
  97. package/dist/engine/core/workspace-detect.js +7 -6
  98. package/dist/engine/data/personas/registry.json +36 -0
  99. package/dist/engine/guardian/adjudication.js +5 -5
  100. package/dist/engine/guardian/analysis-emit.js +19 -28
  101. package/dist/engine/guardian/cli.js +150 -51
  102. package/dist/engine/guardian/coverage.js +1 -1
  103. package/dist/engine/guardian/diff-coverage/heuristic-tier.js +1 -1
  104. package/dist/engine/guardian/diff-coverage/orchestrator.js +2 -2
  105. package/dist/engine/guardian/pr-check.js +322 -33
  106. package/dist/engine/guardian/pr-comment.js +4 -3
  107. package/dist/engine/history/cli.js +210 -6
  108. package/dist/engine/history/ndjson-store.js +9 -5
  109. package/dist/engine/history/record.js +34 -5
  110. package/dist/engine/history/run-recorder.js +165 -0
  111. package/dist/engine/history/schema.js +25 -7
  112. package/dist/engine/history/store.js +9 -0
  113. package/dist/engine/mcp-server.js +35 -13
  114. package/dist/engine/skills-cli.js +249 -23
  115. package/dist/engine/util/ensure-ascii.js +37 -0
  116. package/dist/engine/workflow-cli.js +6 -6
  117. package/dist/gate-result.d.ts +11 -0
  118. package/dist/gate-result.js +18 -0
  119. package/dist/reporters/testtracker.d.ts +1 -1
  120. package/dist/reporters/testtracker.js +1 -1
  121. package/dist/uninstall.js +12 -5
  122. package/package.json +3 -2
@@ -0,0 +1,247 @@
1
+ #!/usr/bin/env node
2
+ // canary-katana -- quarantine deleted and newly-skipped tests, with provenance.
3
+ //
4
+ // Captures every removed or skipped test into an append-only ledger (who
5
+ // deleted it, when, in which commit, and why), and alarms in exactly one case:
6
+ // the removed test was the last coverage of a symbol `critical-areas.json`
7
+ // marks high-risk.
8
+ //
9
+ // Advisory by default (always exit 0). `--strict` exits 1 only on a real alarm;
10
+ // a degraded run (no critical-area data) stays exit 0 even under `--strict` --
11
+ // a gate that fails on missing data gets muted, and a muted gate is worse than
12
+ // none.
13
+ //
14
+ // Invoked via `canary skills run canary-katana -- [options]`.
15
+
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+
19
+ import {
20
+ createParser,
21
+ formatUsageError,
22
+ EXIT_USAGE,
23
+ } from '../../../lib/parse-args.mjs';
24
+ import * as diffscan from './diffscan.mjs';
25
+ import * as alarm from './alarm.mjs';
26
+ import * as ledger from './ledger.mjs';
27
+
28
+ // --- no-silent-abstention (#508 D2, skill-CLI convention half) ---------------
29
+ //
30
+ // Skill CLIs are deliberately self-contained -- no engine import -- so they
31
+ // cannot call `gateOutcome`. They honour the doctrine by CONVENTION, emitting
32
+ // the same greppable line the engine helper does; the skill-layer conformance
33
+ // registry (agents/skills/test/gate-conformance.test.ts) holds them to it.
34
+ //
35
+ // U+26A0 / U+2014 as escapes so this source stays ASCII, matching
36
+ // ts/src/core/gate-result.ts.
37
+ const ABSTAINED_LINE =
38
+ '\u{26A0} Abstained \u{2014} verified zero items; this is not a pass.';
39
+
40
+ const PREFIX = 'canary-katana:';
41
+
42
+ const USAGE =
43
+ 'usage: canary-katana [-h] [--repo PATH] [--diff-file PATH] [--ledger PATH]\n' +
44
+ ' [--critical-areas PATH] [--json] [--strict] [--no-write]\n' +
45
+ '\n' +
46
+ 'Quarantine deleted and newly-skipped tests into an append-only ledger with\n' +
47
+ 'provenance, and alarm when a removal drops the last coverage of a critical\n' +
48
+ 'area.\n' +
49
+ '\n' +
50
+ 'options:\n' +
51
+ ' -h, --help show this help message and exit\n' +
52
+ ' --repo PATH repository to inspect (default: .)\n' +
53
+ ' --diff-file PATH read the diff from a file instead of git\n' +
54
+ ' --ledger PATH ledger location (default: <repo>/.canary/quarantine.json)\n' +
55
+ ' --critical-areas PATH critical-areas.json used to raise alarms\n' +
56
+ ' --json emit machine-readable output instead of human text\n' +
57
+ ' --strict exit 1 on a real alarm (degraded runs stay 0)\n' +
58
+ ' --no-write do not append to the ledger (read-only run)';
59
+
60
+ /**
61
+ * katana takes no positionals, so any leftover token -- dashed or not -- is a
62
+ * usage error rather than something silently ignored, and `--` has nothing to
63
+ * protect. The four shared invariants (null-prototype lookup, empty-value
64
+ * rejection, arity, `--flag=value`) live in the shared parser; see #479 for why
65
+ * they stopped living here.
66
+ */
67
+ export const CLI_SPEC = {
68
+ prog: 'canary-katana',
69
+ booleans: {
70
+ '--json': 'json',
71
+ '--strict': 'strict',
72
+ '--no-write': 'noWrite',
73
+ },
74
+ values: {
75
+ '--repo': { key: 'repo' },
76
+ '--diff-file': { key: 'diffFile' },
77
+ '--ledger': { key: 'ledger' },
78
+ '--critical-areas': { key: 'criticalAreas' },
79
+ },
80
+ defaults: { repo: '.' },
81
+ };
82
+
83
+ const parseArgs = createParser(CLI_SPEC);
84
+
85
+ /**
86
+ * Return { text, base }. `base` is a git ref when one is resolvable. With
87
+ * diffFile the diff is read verbatim and git is still consulted (best-effort)
88
+ * for provenance; without it the diff is computed from the repo's own history.
89
+ */
90
+ function loadDiff(repo, diffFile) {
91
+ if (diffFile) {
92
+ if (!fs.existsSync(diffFile)) {
93
+ const err = new Error(`diff file not found: ${diffFile}`);
94
+ err.notFound = true;
95
+ throw err;
96
+ }
97
+ const text = fs.readFileSync(diffFile, 'utf8');
98
+ let base = null;
99
+ try {
100
+ base = diffscan.resolveBase(repo, null);
101
+ } catch {
102
+ base = null; // non-git repo: provenance stays unknown
103
+ }
104
+ return { text, base };
105
+ }
106
+ const base = diffscan.resolveBase(repo, null);
107
+ return { text: diffscan.diffText(repo, base), base };
108
+ }
109
+
110
+ function provenance(repo, base, file) {
111
+ if (base === null) return null;
112
+ try {
113
+ return diffscan.commitForFile(repo, base, file);
114
+ } catch {
115
+ return null; // missing history is unknown, not fatal
116
+ }
117
+ }
118
+
119
+ function toEntries(repo, base, deletions) {
120
+ return deletions.map((d) => {
121
+ const commit = provenance(repo, base, d.file);
122
+ return ledger.LedgerEntry({
123
+ test: d.name,
124
+ file: d.file,
125
+ kind: d.kind,
126
+ marker: d.marker,
127
+ commit: commit ? commit.sha : '',
128
+ author: commit ? commit.author : 'unknown',
129
+ date: commit ? commit.date : '',
130
+ reason: commit ? commit.subject : '',
131
+ // The `Ticket:` trailer is one way an issue link arrives; a quarantine
132
+ // producer writing a caused row is the other. Both land in `issue`.
133
+ issue: commit ? commit.ticket : '',
134
+ });
135
+ });
136
+ }
137
+
138
+ function renderText(deletions, findings, degraded, scanned) {
139
+ // #508: katana's denominator is the DIFF it read, not the deletions it found.
140
+ // Zero deletions in a 500-line diff is a real result; zero deletions in an
141
+ // EMPTY diff means nothing was examined at all. `0 deletion(s) captured` reads
142
+ // identically in both cases, which is precisely the shape the doctrine bans.
143
+ if (!scanned) {
144
+ return (
145
+ `${ABSTAINED_LINE} The diff was empty, so no deleted test could be ` +
146
+ 'captured. Check --repo/--diff-file, or that the range actually ' +
147
+ 'contains changes.'
148
+ );
149
+ }
150
+ const lines = [`${deletions.length} deletion(s) captured.`];
151
+ if (degraded) lines.push(alarm.DEGRADED_NOTICE);
152
+ for (const f of findings) {
153
+ lines.push(
154
+ ` [${f.severity.value}] ${f.file}::${f.test} removed the last coverage of ${f.area}`,
155
+ );
156
+ }
157
+ return lines.join('\n');
158
+ }
159
+
160
+ export function main(argv = []) {
161
+ const { opts: args, help, error } = parseArgs(argv);
162
+
163
+ // FIRST, before loadDiff and before any ledger write: a usage request or a
164
+ // typo must never mutate the working tree.
165
+ if (help) {
166
+ console.log(USAGE);
167
+ return 0;
168
+ }
169
+ if (error) {
170
+ console.error(formatUsageError(CLI_SPEC.prog, error));
171
+ return EXIT_USAGE;
172
+ }
173
+
174
+ const repo = args.repo;
175
+ // `!= null`, not a truthiness test: "--ledger was not given" and "--ledger
176
+ // was given an empty path" are different situations, and only the first one
177
+ // may fall back to the default. (An empty value is rejected at parse time,
178
+ // so this branch is now unreachable with '' -- the explicit null check keeps
179
+ // it that way if the parser ever loosens.)
180
+ const ledgerPath =
181
+ args.ledger != null
182
+ ? args.ledger
183
+ : path.join(repo, '.canary', 'quarantine.json');
184
+
185
+ let diff;
186
+ let base;
187
+ try {
188
+ ({ text: diff, base } = loadDiff(repo, args.diffFile));
189
+ } catch (exc) {
190
+ if (exc.notFound) {
191
+ console.error(`${PREFIX} ${exc.message}`);
192
+ return 1;
193
+ }
194
+ console.error(`${PREFIX} could not read diff: ${exc.message}`);
195
+ return 1;
196
+ }
197
+
198
+ // Non-blank diff text is the denominator probe: `loadDiff` succeeding does
199
+ // not mean it returned anything to scan.
200
+ const scanned = diff.trim().length > 0;
201
+ const deletions = diffscan.findDeletions(diff);
202
+ const entries = toEntries(repo, base, deletions);
203
+
204
+ if (!args.noWrite) {
205
+ try {
206
+ ledger.appendEntries(ledgerPath, entries);
207
+ } catch (exc) {
208
+ console.error(`${PREFIX} ${exc.message}`);
209
+ return 1;
210
+ }
211
+ }
212
+
213
+ const areas = alarm.loadCriticalAreas(args.criticalAreas);
214
+ const degraded = !areas.available;
215
+ const findings = alarm.buildFindings(deletions, areas, repo);
216
+
217
+ if (args.json) {
218
+ const payload = {
219
+ schema_version: ledger.SCHEMA_VERSION,
220
+ captured: deletions.map(diffscan.deletionToDict),
221
+ findings: findings.map(alarm.findingToDict),
222
+ ledger: String(ledgerPath),
223
+ };
224
+ if (degraded) payload.degraded_notice = alarm.DEGRADED_NOTICE;
225
+ // Additive (#508): a consumer can distinguish "no deletions" from "nothing
226
+ // examined" without parsing prose.
227
+ payload.checked = scanned ? 1 : 0;
228
+ payload.abstained = !scanned;
229
+ console.log(JSON.stringify(payload, null, 2));
230
+ } else {
231
+ console.log(renderText(deletions, findings, degraded, scanned));
232
+ }
233
+
234
+ // Advisory by default (D3); --strict inherits EXIT_ABSTAINED (3) on an empty
235
+ // diff, distinct from 1 ("captured a real deletion").
236
+ if (args.strict && !scanned) return 3;
237
+ return args.strict && findings.length ? 1 : 0;
238
+ }
239
+
240
+ // Direct execution (the skill runner execs this file via its shebang).
241
+ //
242
+ // `process.exitCode`, not `process.exit()`: a large `--json` payload exceeds
243
+ // the pipe buffer, and `process.exit` tears the process down mid-write, leaving
244
+ // truncated JSON that still exits 0 (#791).
245
+ if (import.meta.url === `file://${process.argv[1]}`) {
246
+ process.exitCode = main(process.argv.slice(2));
247
+ }
@@ -0,0 +1,183 @@
1
+ // ledger -- the quarantine record for tests that are out of the suite.
2
+ //
3
+ // Every captured deletion is written with its provenance (who, when, what
4
+ // commit, why) so a test that vanishes leaves a trail instead of a silent gap.
5
+ // The ledger is de-duplicated: re-running the capture on the same change adds
6
+ // nothing, and a batch of new entries is sorted for a stable on-disk order.
7
+ //
8
+ // SCHEMA v2 (#771) adds the WHY a row is out, alongside the provenance of how it
9
+ // left: `cause`, `issue`, `expiry`. Two fields deserve their separation --
10
+ // `reason` is DERIVED (the commit subject, "what change did this"), `cause` is
11
+ // ASSERTED (a judgement someone made, "why is it out"). Collapsing them would
12
+ // dress an auto-derived string up as a claim a person stands behind.
13
+ //
14
+ // v2 also makes the ledger no longer purely append-only, for one narrow case
15
+ // documented at `appendEntries`: a row that states a cause SUPERSEDES a causeless
16
+ // row for the same (test, file). See the comment there for why leaving both is
17
+ // worse than replacing one.
18
+
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+
22
+ export const SCHEMA_VERSION = 2;
23
+
24
+ // The fields, in the order they define a row's identity for de-duplication.
25
+ const FIELDS = [
26
+ 'test',
27
+ 'file',
28
+ 'kind',
29
+ 'marker',
30
+ 'commit',
31
+ 'author',
32
+ 'date',
33
+ 'reason',
34
+ 'cause',
35
+ 'issue',
36
+ 'expiry',
37
+ ];
38
+
39
+ /**
40
+ * Why a test is out of the suite. The two middle values are the ones where the
41
+ * TEST IS CORRECT and someone else owns the fix, so a producer must require an
42
+ * `issue` for them -- an untracked "the product is broken" note decays into an
43
+ * unexplained skip within a release.
44
+ */
45
+ export const CAUSES = ['flaky', 'product-defect', 'blocked-data', 'obsolete'];
46
+
47
+ /** Causes for which a row without an `issue` is not a real record. */
48
+ export const CAUSES_REQUIRING_ISSUE = ['product-defect', 'blocked-data'];
49
+
50
+ /**
51
+ * @typedef {{test: string, file: string, kind: string, marker: string,
52
+ * commit: string, author: string, date: string, reason: string,
53
+ * cause: string, issue: string, expiry: string}} LedgerRow
54
+ */
55
+
56
+ /** Normalize an entry-like object into a row with the canonical field order. */
57
+ export function LedgerEntry(fields) {
58
+ const row = {};
59
+ for (const f of FIELDS) row[f] = fields[f] ?? '';
60
+ // v1 wrote the tracker link as `ticket` (#781). It is the same fact under a
61
+ // different name, so it migrates onto `issue` rather than being dropped or
62
+ // kept alongside -- two fields answering "what is this waiting on" is how a
63
+ // consumer ends up reading the empty one. `Ticket:` survives as the name of
64
+ // the COMMIT TRAILER that populates it, which is a mechanism, not a schema.
65
+ if (!row.issue && typeof fields.ticket === 'string')
66
+ row.issue = fields.ticket;
67
+ return row;
68
+ }
69
+
70
+ // The subset that defines a row's IDENTITY for de-duplication.
71
+ //
72
+ // `issue` and `expiry` are excluded, for the reason #781 excluded `ticket`:
73
+ // identity is what happened -- which test, in which file, muted how, by which
74
+ // commit and why -- and a tracker link is an attribute of that event rather
75
+ // than part of it. Including them would also break the append-only guarantee
76
+ // across this schema change, since rows written before the fields existed key
77
+ // as `''` and the same capture re-run with an issue present would hash
78
+ // differently and append a duplicate of a row already on disk.
79
+ //
80
+ // `cause` IS identity, and deliberately so: `appendEntries` decides supersede
81
+ // on whether a row states one, so a caused and a causeless row for the same
82
+ // test must remain distinguishable here for that decision to have anything to
83
+ // act on.
84
+ const IDENTITY_FIELDS = FIELDS.filter((f) => f !== 'issue' && f !== 'expiry');
85
+
86
+ const key = (row) => IDENTITY_FIELDS.map((f) => row[f] ?? '').join('\u0000');
87
+
88
+ /** The (test, file) pair a supersede decision is made on. */
89
+ const pair = (row) => `${row.test ?? ''}\u0000${row.file ?? ''}`;
90
+
91
+ /**
92
+ * Load the ledger document, or an empty one when the file is absent.
93
+ * Throws on unparseable JSON or a non-object top level -- a corrupt ledger is a
94
+ * hard error the caller must surface, not silently overwrite.
95
+ *
96
+ * Every row is normalized through `LedgerEntry`, so a v1 file read here comes
97
+ * back with the v2 fields present and empty. That is what makes writing
98
+ * `schema_version: 2` honest: the version claims "these rows have these fields",
99
+ * and after normalization they do. Stamping the version over un-migrated rows
100
+ * would make it a promise the file does not keep.
101
+ * @returns {{schema_version: number, entries: LedgerRow[]}}
102
+ */
103
+ export function load(filePath) {
104
+ if (!fs.existsSync(filePath)) {
105
+ return { schema_version: SCHEMA_VERSION, entries: [] };
106
+ }
107
+ let data;
108
+ try {
109
+ data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
110
+ } catch (exc) {
111
+ throw new Error(`ledger is not valid JSON: ${filePath}: ${exc.message}`);
112
+ }
113
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
114
+ throw new Error(`ledger top level must be an object: ${filePath}`);
115
+ }
116
+ if (!('schema_version' in data)) data.schema_version = SCHEMA_VERSION;
117
+ if (!('entries' in data)) data.entries = [];
118
+ if (!Array.isArray(data.entries)) {
119
+ throw new Error(`ledger entries must be an array: ${filePath}`);
120
+ }
121
+ data.entries = data.entries.map((e) => LedgerEntry(e));
122
+ return data;
123
+ }
124
+
125
+ /**
126
+ * Append `entries` to the ledger at `filePath` and persist it. New entries are
127
+ * sorted by (file, test) and de-duplicated against the batch and disk.
128
+ *
129
+ * ONE ROW PER (test, file) MAY STATE A CAUSE, and a caused row wins:
130
+ *
131
+ * - a new row WITH a cause replaces a causeless row for the same pair
132
+ * - a new row WITHOUT a cause is dropped when a caused row already exists
133
+ *
134
+ * Without this, katana recording `{kind: 'skipped', cause: ''}` and a quarantine
135
+ * writer recording `{kind: 'skipped', cause: 'product-defect', issue: '#123'}`
136
+ * differ in `key()` and BOTH persist. A consumer that fails on an unlinked
137
+ * quarantine (canary-ci-ready does) then fails on the causeless row while the
138
+ * linked row sits beside it -- the ledger contradicting itself about one test.
139
+ *
140
+ * History is not lost to this: rows that differ in cause-bearing state are the
141
+ * only ones that collapse. Two caused rows, or two causeless rows, keep the
142
+ * full-field identity and both remain.
143
+ */
144
+ export function appendEntries(filePath, entries) {
145
+ const doc = load(filePath);
146
+ const existing = doc.entries;
147
+ const seen = new Set(existing.map(key));
148
+ const causedPairs = new Set(existing.filter((r) => r.cause).map(pair));
149
+
150
+ const newRows = entries
151
+ .map((e) => LedgerEntry(e))
152
+ .sort(
153
+ (a, b) => a.file.localeCompare(b.file) || a.test.localeCompare(b.test),
154
+ );
155
+
156
+ for (const row of newRows) {
157
+ const k = key(row);
158
+ if (seen.has(k)) continue;
159
+ const p = pair(row);
160
+
161
+ if (row.cause) {
162
+ // Supersede: drop any causeless row for this pair, then take its place.
163
+ for (let i = existing.length - 1; i >= 0; i--) {
164
+ if (!existing[i].cause && pair(existing[i]) === p) {
165
+ seen.delete(key(existing[i]));
166
+ existing.splice(i, 1);
167
+ }
168
+ }
169
+ causedPairs.add(p);
170
+ } else if (causedPairs.has(p)) {
171
+ // A causeless row must never sit next to a caused one for the same test.
172
+ continue;
173
+ }
174
+
175
+ seen.add(k);
176
+ existing.push(row);
177
+ }
178
+
179
+ doc.schema_version = SCHEMA_VERSION;
180
+ fs.mkdirSync(path.dirname(path.resolve(filePath)), { recursive: true });
181
+ fs.writeFileSync(filePath, `${JSON.stringify(doc, null, 2)}\n`, 'utf8');
182
+ return doc;
183
+ }
@@ -0,0 +1,129 @@
1
+ ---
2
+ name: canary-pr-guardian
3
+ description: >
4
+ PR/pre-commit test-guardian orchestrator: runs the deterministic Tier-0
5
+ diff-coverage pass, audits affected tests via canary-test-reviewer (Tier 1),
6
+ and — at the desk with authorTests opt-in — authors missing tests via
7
+ canary-test-author (Tier 2), staging them and blocking the commit once for
8
+ human review. Use to guard a change's test quality before it lands.
9
+ ---
10
+
11
+ # Canary: PR Guardian
12
+
13
+ Guards a change's test quality before it lands. Composes the deterministic
14
+ Tier-0 diff-coverage engine with two native agents — `canary-test-reviewer`
15
+ (read-only audit) and `canary-test-author` (authoring) — under a strict
16
+ write-safety model. This is the **Option A** driver: the engine never calls an
17
+ LLM; **this skill** invokes the agents in-session and enforces
18
+ stage-and-block-once.
19
+
20
+ **On the tier numbers.** Here they are the values of
21
+ `canary guardian pr-check --tier 0|1|2`, not a repo-wide capability scale — Tier
22
+ 1 means "the `--tier 1` pass," which is the agent audit. `Tier-0` is the one
23
+ number with a repo-wide meaning (deterministic, no network, no agent), and
24
+ `Tier-1`/`Tier-2` are guardian-local by
25
+ [ADR 0015](../../../../docs/knowledge/decisions/0015-skill-capability-vocabulary.md).
26
+ Do not carry them into other skills.
27
+
28
+ ## When to Use
29
+
30
+ - Before opening or updating a PR, to check that new/changed code is tested.
31
+ - As a pre-commit companion when `preCommit.authorTests: true` is set and you
32
+ want the guardian to author the missing tests for you (at the desk only).
33
+ - NOT in CI for Tier-2 write-back — that is a NON-GOAL. CI runs Tier-0 only (the
34
+ `CANARY_GUARDIAN_AGENT` env is unset there).
35
+
36
+ ## Safety model (non-negotiable)
37
+
38
+ - **NEVER commit or push.** This skill only authors and `git add`s. The human
39
+ reviews the staged tests and re-commits.
40
+ - **Honor every `skipped` reason** from `author-plan` verbatim (opt-in-off /
41
+ tier / fork / collision / loop-guard). Never override a skip.
42
+ - **Block once.** When `block.block == true`, print the block message and stop —
43
+ leave the staged tests for the human. The loop-guard sentinel you write in
44
+ Phase 3 is what stops the guardian re-authoring over its own output on the
45
+ next run. It is stamped with the current `HEAD` and expires by itself once the
46
+ human's review commit moves `HEAD` — never delete it yourself.
47
+ - **Authoring is opt-in.** No `preCommit.authorTests: true` ⇒ no writes, ever.
48
+
49
+ ## Phases
50
+
51
+ ### Phase 0 — Deterministic scope
52
+
53
+ Run the Tier-0 pass and read its findings:
54
+
55
+ ```bash
56
+ canary guardian pr-check --format json
57
+ ```
58
+
59
+ Findings are `untested-new-code` gaps. If there are none, report clean and stop.
60
+
61
+ ### Phase 1 — Quality audit (Tier ≥ 1, read-only)
62
+
63
+ Export the availability signal so the probe reports the ceiling, then audit the
64
+ affected tests with `canary-test-reviewer`:
65
+
66
+ ```bash
67
+ export CANARY_GUARDIAN_AGENT=1 # or 2 when authoring is enabled
68
+ ```
69
+
70
+ If this checkout is a **fork** (an untrusted/read-only context — detect it, e.g.
71
+ `git config --get remote.origin.url` pointing at a fork, or a CI fork PR), arm
72
+ the fork guard so the engine's safety layer never authors on it:
73
+
74
+ ```bash
75
+ export CANARY_GUARDIAN_IS_FORK=1 # any value other than "0"/unset means fork
76
+ ```
77
+
78
+ Leave `CANARY_GUARDIAN_IS_FORK` unset (or `0`) at your own desk on a trusted
79
+ checkout. The guard fails CLOSED: any ambiguous value is treated as a fork and
80
+ authoring is skipped.
81
+
82
+ Use the `canary-test-reviewer` agent to review the affected tests. This is
83
+ **read-only** — surface weak-test findings; write nothing.
84
+
85
+ ### Phase 2 — Authoring plan (Tier 2 + opt-in)
86
+
87
+ Ask the engine's safety layer for the plan (intents + block decision):
88
+
89
+ ```bash
90
+ canary guardian author-plan --json
91
+ ```
92
+
93
+ The JSON is `{"intents": [...], "block": {...}}`. Each intent carries `status`
94
+ (`planned` | `authored` | `skipped`), `target_path`, `requirement`, and a
95
+ `skip_reason` when skipped. **Do not author anything the plan skipped** — the
96
+ Engine guards (opt-in, fork, collision, loop-guard) are authoritative.
97
+
98
+ ### Phase 3 — Author, stage, block once
99
+
100
+ For each intent with `status: "planned"`, use the `canary-test-author` agent
101
+ with the intent's `requirement` as the task and `target_path` as the
102
+ destination. **Never overwrite an existing file at `target_path`.** The engine's
103
+ collision guard runs at plan time, so between planning and writing another
104
+ PR/session may have created the file (a TOCTOU window). If the target already
105
+ exists at write time, skip that intent and report it — do not clobber it. Then
106
+ stage the authored files:
107
+
108
+ ```bash
109
+ git add <target_path>
110
+ ```
111
+
112
+ When `block.block == true`, record the guardian-authored paths in the loop-guard
113
+ sentinel BEFORE blocking, by running the deterministic producer command once per
114
+ authored path:
115
+
116
+ ```bash
117
+ canary guardian mark-authored --path <target_path> [--path <target_path> ...]
118
+ ```
119
+
120
+ This is a real CLI step (not something you `touch` yourself): it writes the
121
+ sentinel inside the real git dir and records exactly the paths you authored,
122
+ stamped with the current `HEAD`. `author-plan` reads it on the next run and
123
+ returns a `loop-guard` skip **while `HEAD` is unchanged**, so the guardian never
124
+ authors on top of its own output — and once the human commits the reviewed
125
+ tests, `HEAD` moves and authoring re-enables itself.
126
+
127
+ Then print `block.message` (the "N test(s) authored & staged — review and
128
+ re-commit" notice) and **stop**. Do not commit. The human reviews the staged
129
+ tests and re-commits.
@@ -0,0 +1,17 @@
1
+ name: canary-pr-guardian
2
+ version: '1.0.0'
3
+ description:
4
+ PR/pre-commit test-guardian orchestrator — Tier-0 diff-coverage, Tier-1
5
+ quality audit via canary-test-reviewer, and at-desk Tier-2 authoring via
6
+ canary-test-author with stage-and-block-once review.
7
+ stability: static
8
+ triggers:
9
+ - manual
10
+ platforms:
11
+ - claude-code
12
+ type: rigid
13
+ tools: []
14
+ tier: 1
15
+ depends_on:
16
+ - canary-test-reviewer
17
+ - canary-test-author