clud-bug 0.7.0-rc.21 → 0.7.0-rc.23

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 (78) hide show
  1. package/data/canonical-v1.json +39 -34
  2. package/data/rulesets/baseline.json +29 -0
  3. package/data/rulesets/clud-bug.json +39 -0
  4. package/data/rulesets/public-guard.json +29 -0
  5. package/data/rulesets/skdd.json +42 -0
  6. package/dist/cli/configure-github.d.ts +13 -4
  7. package/dist/cli/configure-github.d.ts.map +1 -1
  8. package/dist/cli/configure-github.js +73 -27
  9. package/dist/cli/configure-github.js.map +1 -1
  10. package/dist/cli/hooks.d.ts.map +1 -1
  11. package/dist/cli/hooks.js +5 -1
  12. package/dist/cli/hooks.js.map +1 -1
  13. package/dist/cli/main.d.ts.map +1 -1
  14. package/dist/cli/main.js +33 -10
  15. package/dist/cli/main.js.map +1 -1
  16. package/dist/cli/post-check-run.d.ts +2 -0
  17. package/dist/cli/post-check-run.d.ts.map +1 -1
  18. package/dist/cli/post-check-run.js +124 -5
  19. package/dist/cli/post-check-run.js.map +1 -1
  20. package/dist/cli/review-prompt.d.ts +11 -1
  21. package/dist/cli/review-prompt.d.ts.map +1 -1
  22. package/dist/cli/review-prompt.js +141 -37
  23. package/dist/cli/review-prompt.js.map +1 -1
  24. package/dist/core/check-verdict.d.ts +1 -1
  25. package/dist/core/check-verdict.d.ts.map +1 -1
  26. package/dist/core/check-verdict.js +14 -1
  27. package/dist/core/check-verdict.js.map +1 -1
  28. package/dist/core/configure-github.d.ts +184 -119
  29. package/dist/core/configure-github.d.ts.map +1 -1
  30. package/dist/core/configure-github.js +407 -209
  31. package/dist/core/configure-github.js.map +1 -1
  32. package/dist/core/index.d.ts +4 -1
  33. package/dist/core/index.d.ts.map +1 -1
  34. package/dist/core/index.js +12 -1
  35. package/dist/core/index.js.map +1 -1
  36. package/dist/core/invariants.d.ts +46 -0
  37. package/dist/core/invariants.d.ts.map +1 -0
  38. package/dist/core/invariants.js +96 -0
  39. package/dist/core/invariants.js.map +1 -0
  40. package/dist/core/notary-bundle.d.ts +95 -0
  41. package/dist/core/notary-bundle.d.ts.map +1 -0
  42. package/dist/core/notary-bundle.js +129 -0
  43. package/dist/core/notary-bundle.js.map +1 -0
  44. package/dist/core/notary-validate.d.ts +86 -0
  45. package/dist/core/notary-validate.d.ts.map +1 -0
  46. package/dist/core/notary-validate.js +270 -0
  47. package/dist/core/notary-validate.js.map +1 -0
  48. package/dist/core/prompt-builder.js +2 -2
  49. package/dist/core/review-schema-zod.d.ts +48 -0
  50. package/dist/core/review-schema-zod.d.ts.map +1 -1
  51. package/dist/core/review-schema-zod.js +5 -0
  52. package/dist/core/review-schema-zod.js.map +1 -1
  53. package/dist/core/review-schema.d.ts +3 -0
  54. package/dist/core/review-schema.d.ts.map +1 -1
  55. package/dist/core/review-schema.js +9 -0
  56. package/dist/core/review-schema.js.map +1 -1
  57. package/dist/core/version.d.ts +1 -1
  58. package/dist/core/version.js +1 -1
  59. package/package.json +1 -1
  60. package/src/cli/configure-github.ts +108 -31
  61. package/src/cli/hooks.ts +5 -1
  62. package/src/cli/main.ts +32 -10
  63. package/src/cli/post-check-run.ts +149 -5
  64. package/src/cli/review-prompt.ts +162 -36
  65. package/src/core/check-verdict.ts +14 -2
  66. package/src/core/configure-github.ts +591 -349
  67. package/src/core/index.ts +46 -0
  68. package/src/core/invariants.ts +123 -0
  69. package/src/core/notary-bundle.ts +196 -0
  70. package/src/core/notary-validate.ts +343 -0
  71. package/src/core/prompt-builder.ts +2 -2
  72. package/src/core/review-schema-zod.ts +5 -0
  73. package/src/core/review-schema.ts +12 -0
  74. package/src/core/version.ts +1 -1
  75. package/templates/skills/design/designing-elite-ui.md +57 -0
  76. package/templates/workflow-py.yml.tmpl +1 -1
  77. package/templates/workflow-ts.yml.tmpl +1 -1
  78. package/templates/workflow.yml.tmpl +1 -1
@@ -25,13 +25,23 @@ import { spawn, spawnSync } from 'node:child_process';
25
25
  import {
26
26
  applyCanonicalRuleset,
27
27
  loadCanonicalV1,
28
+ loadPreset,
29
+ isPresetName,
30
+ DEFAULT_PRESET,
31
+ PRESET_NAMES,
28
32
  type OctokitLike,
29
33
  type ApplyResult,
34
+ type PresetName,
30
35
  } from '../core/configure-github.js';
31
36
 
32
37
  export interface RunConfigureGithubOptions {
33
38
  /** "owner/repo" target — required. */
34
39
  target?: string | null;
40
+ /**
41
+ * Ruleset preset: baseline · clud-bug · skdd · public-guard.
42
+ * Defaults to `skdd`. Invalid values exit 2 (usage error).
43
+ */
44
+ preset?: string;
35
45
  /** Target branch (default `main`). */
36
46
  branch?: string;
37
47
  /** Render diff only; skip PATCH calls. */
@@ -50,13 +60,22 @@ export interface RunConfigureGithubOptions {
50
60
  stderr?: (msg: string) => void;
51
61
  }
52
62
 
53
- const HELP = `clud-bug configure-github — apply SPEC §7 canonical branch protection.
63
+ const HELP = `clud-bug configure-github — one-stop repo setup (conveniences + SPEC §7 ruleset).
54
64
 
55
65
  Usage:
56
66
  clud-bug configure-github <owner>/<repo> [options]
57
67
 
68
+ Applies, in order, for the selected preset:
69
+ 1. Repo conveniences (ALL presets): squash-only merges, auto-merge +
70
+ delete-branch-on-merge on, PR title/body as the squash commit message.
71
+ 2. The canonical branch ruleset for the chosen preset.
72
+
58
73
  Options:
59
- --dry-run Compute diff and print it, but do NOT call PATCH endpoints.
74
+ --preset <name> Ruleset preset: baseline | clud-bug | skdd | public-guard.
75
+ Default: skdd. (baseline = structural only; clud-bug adds
76
+ the clud-bug-review gate; skdd adds SkDD derived-docs
77
+ checks; public-guard = 1 approval + code-owner.)
78
+ --dry-run Compute diff and print it, but do NOT create/update anything.
60
79
  --branch <name> Target branch (default: main).
61
80
  --quiet,-q Suppress progress chatter; emit only the final summary.
62
81
  --json Emit the status payload as JSON (machine consumption).
@@ -68,7 +87,7 @@ Auth (resolved in order):
68
87
 
69
88
  Exit codes:
70
89
  0 success or already-canonical
71
- 1 auth missing, PATCH error, or unrecoverable transport failure
90
+ 1 auth missing, ruleset write error, or unrecoverable transport failure
72
91
  2 CLI usage error (missing target, bad flag)
73
92
  `;
74
93
 
@@ -76,6 +95,8 @@ Exit codes:
76
95
  export interface ConfigureSummary {
77
96
  owner: string;
78
97
  repo: string;
98
+ /** The preset applied (baseline · clud-bug · skdd · public-guard). */
99
+ preset?: string;
79
100
  /** True when the repo already matches canonical-v1 (idempotent no-op). */
80
101
  alreadyCanonical: boolean;
81
102
  /** True when this was a --dry-run (no PATCH calls made). */
@@ -90,20 +111,22 @@ export interface ConfigureSummary {
90
111
  * consumption, key-colon-value for humans.
91
112
  */
92
113
  export function formatConfigureSummary(summary: ConfigureSummary, json: boolean): string {
93
- const { owner, repo, alreadyCanonical, dryRun, changes } = summary;
114
+ const { owner, repo, preset, alreadyCanonical, dryRun, changes } = summary;
94
115
  if (json) {
95
116
  return (
96
- JSON.stringify({ owner, repo, alreadyCanonical, rulesetVersion: 'v1', dryRun, changes }) + '\n'
117
+ JSON.stringify({ owner, repo, preset, alreadyCanonical, rulesetVersion: 'v2', dryRun, changes }) + '\n'
97
118
  );
98
119
  }
120
+ const presetTag = preset ? ` preset: ${preset}` : '';
121
+ const presetParen = preset ? ` (${preset})` : '';
99
122
  if (alreadyCanonical) {
100
- return `ok configure-github: owner: ${owner} repo: ${repo} alreadyCanonical: true rulesetVersion: v1\n`;
123
+ return `ok configure-github: owner: ${owner} repo: ${repo}${presetTag} alreadyCanonical: true rulesetVersion: v2\n`;
101
124
  }
102
125
  const plural = changes === 1 ? '' : 's';
103
126
  if (dryRun) {
104
- return `ok configure-github: dry-run on ${owner}/${repo} — ${changes} change${plural} pending\n`;
127
+ return `ok configure-github: dry-run on ${owner}/${repo}${presetParen} — ${changes} change${plural} pending\n`;
105
128
  }
106
- return `ok configure-github: ${owner}/${repo} converged to canonical-v1 (${changes} change${plural})\n`;
129
+ return `ok configure-github: ${owner}/${repo} converged to canonical-v1${presetParen} (${changes} change${plural})\n`;
107
130
  }
108
131
 
109
132
  /**
@@ -117,6 +140,7 @@ export async function runConfigureGithub(
117
140
  ): Promise<number> {
118
141
  const {
119
142
  target,
143
+ preset: presetOpt,
120
144
  branch = 'main',
121
145
  dryRun = false,
122
146
  quiet = false,
@@ -140,6 +164,15 @@ export async function runConfigureGithub(
140
164
  }
141
165
  const [, owner, repo] = match as unknown as [unknown, string, string];
142
166
 
167
+ // Resolve + validate the preset (usage error before any network call).
168
+ const preset: string = presetOpt ?? DEFAULT_PRESET;
169
+ if (!isPresetName(preset)) {
170
+ stderr(
171
+ `clud-bug configure-github: unknown preset "${preset}" (valid: ${PRESET_NAMES.join(', ')}).\n`,
172
+ );
173
+ return 2;
174
+ }
175
+
143
176
  const token = await resolveToken();
144
177
  if (!token) {
145
178
  stderr(
@@ -151,7 +184,7 @@ export async function runConfigureGithub(
151
184
 
152
185
  if (!quiet) {
153
186
  stdout(
154
- `\u{1F41B} configure-github: applying canonical-v1 ruleset to ${owner}/${repo} (branch=${branch})\n`,
187
+ `\u{1F41B} configure-github: applying "${preset}" preset (conveniences + ruleset) to ${owner}/${repo} (branch=${branch})\n`,
155
188
  );
156
189
  }
157
190
 
@@ -174,18 +207,19 @@ export async function runConfigureGithub(
174
207
  owner,
175
208
  repo,
176
209
  branch,
210
+ preset,
177
211
  dryRun,
178
212
  });
179
213
  } catch (err) {
180
214
  stderr(
181
- `clud-bug configure-github: ${dryRun ? 'failed to read current state' : 'PATCH failed'}: ${stringifyError(err)}\n`,
215
+ `clud-bug configure-github: ${dryRun ? 'failed to read current state' : 'ruleset write failed'}: ${stringifyError(err)}\n`,
182
216
  );
183
217
  return 1;
184
218
  }
185
219
 
186
220
  if (result.alreadyCanonical) {
187
221
  if (!quiet) stdout(' No changes — repo already matches canonical-v1.\n');
188
- stdout(formatConfigureSummary({ owner, repo, alreadyCanonical: true, dryRun, changes: 0 }, json));
222
+ stdout(formatConfigureSummary({ owner, repo, preset, alreadyCanonical: true, dryRun, changes: 0 }, json));
189
223
  return 0;
190
224
  }
191
225
 
@@ -196,11 +230,11 @@ export async function runConfigureGithub(
196
230
  }
197
231
 
198
232
  if (dryRun) {
199
- stdout(formatConfigureSummary({ owner, repo, alreadyCanonical: false, dryRun: true, changes: result.changes.length }, json));
233
+ stdout(formatConfigureSummary({ owner, repo, preset, alreadyCanonical: false, dryRun: true, changes: result.changes.length }, json));
200
234
  return 0;
201
235
  }
202
236
 
203
- stdout(formatConfigureSummary({ owner, repo, alreadyCanonical: false, dryRun: false, changes: result.changes.length }, json));
237
+ stdout(formatConfigureSummary({ owner, repo, preset, alreadyCanonical: false, dryRun: false, changes: result.changes.length }, json));
204
238
  return 0;
205
239
  }
206
240
 
@@ -242,9 +276,9 @@ export function ghCliOctokit(token: string): OctokitLike {
242
276
  args.push('--input', '-');
243
277
  }
244
278
  args.push(path);
245
- // Add an Accept header so 404s return the structured error, not a
246
- // friendlier shell hint. The wrapping err.message detection in
247
- // `isBranchNotProtected` keys on the structured form.
279
+ // Pin the rulesets API media type; the wrapping error carries the
280
+ // parsed HTTP status (from `HTTP NNN` in gh's stderr) so transport
281
+ // failures surface with a real status code, not a shell hint.
248
282
  args.push('-H', 'Accept: application/vnd.github+json');
249
283
  const child = spawn('gh', args, {
250
284
  env,
@@ -289,26 +323,67 @@ export function ghCliOctokit(token: string): OctokitLike {
289
323
 
290
324
  return {
291
325
  repos: {
292
- async getBranchProtection({ owner, repo, branch }) {
326
+ async getRepoRulesets({ owner, repo, includes_parents }) {
327
+ // `?per_page=100` (not `--paginate`) to grab all rulesets in one
328
+ // call — a repo realistically has a handful, and --paginate emits
329
+ // multiple concatenated JSON arrays that our single JSON.parse can't
330
+ // consume (see the v0.6.30 --paginate → ?per_page=100 decision).
331
+ const parents = includes_parents === undefined ? false : includes_parents;
293
332
  const data = await ghApi<unknown>(
294
333
  'GET',
295
- `/repos/${owner}/${repo}/branches/${branch}/protection`,
334
+ `/repos/${owner}/${repo}/rulesets?per_page=100&includes_parents=${parents}`,
296
335
  );
297
- return { data: data as Awaited<ReturnType<OctokitLike['repos']['getBranchProtection']>>['data'] };
336
+ return {
337
+ data: (Array.isArray(data) ? data : []) as Awaited<
338
+ ReturnType<OctokitLike['repos']['getRepoRulesets']>
339
+ >['data'],
340
+ };
298
341
  },
299
- async updateBranchProtection({ owner, repo, branch, ...rest }) {
300
- return ghApi(
301
- 'PUT',
302
- `/repos/${owner}/${repo}/branches/${branch}/protection`,
303
- rest,
342
+ async getRepoRuleset({ owner, repo, ruleset_id }) {
343
+ const data = await ghApi<unknown>(
344
+ 'GET',
345
+ `/repos/${owner}/${repo}/rulesets/${ruleset_id}`,
304
346
  );
347
+ return {
348
+ data: data as Awaited<
349
+ ReturnType<OctokitLike['repos']['getRepoRuleset']>
350
+ >['data'],
351
+ };
305
352
  },
306
- async get({ owner, repo }) {
353
+ async createRepoRuleset({ owner, repo, ...body }) {
307
354
  const data = await ghApi<unknown>(
308
- 'GET',
309
- `/repos/${owner}/${repo}`,
355
+ 'POST',
356
+ `/repos/${owner}/${repo}/rulesets`,
357
+ body,
358
+ );
359
+ return {
360
+ data: data as Awaited<
361
+ ReturnType<OctokitLike['repos']['createRepoRuleset']>
362
+ >['data'],
363
+ };
364
+ },
365
+ async updateRepoRuleset({ owner, repo, ruleset_id, ...body }) {
366
+ const data = await ghApi<unknown>(
367
+ 'PUT',
368
+ `/repos/${owner}/${repo}/rulesets/${ruleset_id}`,
369
+ body,
310
370
  );
311
- return { data: data as Awaited<ReturnType<OctokitLike['repos']['get']>>['data'] };
371
+ return {
372
+ data: data as Awaited<
373
+ ReturnType<OctokitLike['repos']['updateRepoRuleset']>
374
+ >['data'],
375
+ };
376
+ },
377
+ // Repo-level conveniences (universal hygiene): GET the repo settings,
378
+ // PATCH the drifted merge/branch-cleanup fields. Restored alongside the
379
+ // ruleset applier so `configure-github` is the one-stop setup command.
380
+ async get({ owner, repo }) {
381
+ const data = await ghApi<unknown>('GET', `/repos/${owner}/${repo}`);
382
+ return {
383
+ data: data as Awaited<
384
+ ReturnType<OctokitLike['repos']['get']>
385
+ >['data'],
386
+ };
312
387
  },
313
388
  async update({ owner, repo, ...rest }) {
314
389
  return ghApi('PATCH', `/repos/${owner}/${repo}`, rest);
@@ -323,7 +398,9 @@ function stringifyError(err: unknown): string {
323
398
  }
324
399
 
325
400
  /**
326
- * Re-exported so callers can preload the ruleset (e.g. for a `--show-ruleset`
327
- * flag in future). Currently exists for parity with the App's pattern.
401
+ * Re-exported so callers can preload a ruleset (e.g. for a `--show-ruleset`
402
+ * flag in future). `loadCanonicalV1` resolves the default (skdd) preset;
403
+ * `loadPreset(name)` picks a specific variant.
328
404
  */
329
- export { loadCanonicalV1 };
405
+ export { loadCanonicalV1, loadPreset };
406
+ export type { PresetName };
package/src/cli/hooks.ts CHANGED
@@ -57,7 +57,11 @@ export function buildCommitReviewCommand(pin: string = 'next'): string {
57
57
  `if [ -n "$ev" ]; then case "$ev" in *'git commit'*|*'logmind log'*) ;; *) exit 0 ;; esac; fi`,
58
58
  `sha=$(git rev-parse HEAD 2>/dev/null) || exit 0`,
59
59
  `gitdir=$(git rev-parse --git-dir 2>/dev/null) || exit 0`,
60
- `marker="$gitdir/clud-bug-last-commit-review"`,
60
+ // Pure LOOP-GUARD, not an attestation (§10.3.3): records only that the recipe
61
+ // was already SURFACED for this SHA (avoids a re-review on an amend / double
62
+ // fire). It never means "reviewed" and never yields a green check — the only
63
+ // authoritative "reviewed" state is the notary-issued `clud-bug-review` check.
64
+ `marker="$gitdir/clud-bug-hook-fired"`,
61
65
  `[ "$(cat "$marker" 2>/dev/null)" = "$sha" ] && exit 0`,
62
66
  // H4 — one retry on a transient npx/network blip (a stale lock, a slow
63
67
  // registry) before giving up, so a hiccup doesn't silently skip the review.
package/src/cli/main.ts CHANGED
@@ -71,7 +71,7 @@ function parseArgs(argv) {
71
71
  withLocalReview: false,
72
72
  withHooks: false,
73
73
  // rc.16: `clud-bug init --with-design` installs the design-critic kit
74
- // (3 `kind: design` skills) and flips the off-by-default `design` block to
74
+ // (4 `kind: design` skills) and flips the off-by-default `design` block to
75
75
  // enabled so the visual review lens runs (local recipe + hosted bot).
76
76
  withDesign: false,
77
77
  // rc.18: `clud-bug init --local-only` installs MAX MODE (skills + slash
@@ -82,8 +82,10 @@ function parseArgs(argv) {
82
82
  localOnly: false,
83
83
  // v0.7.0-rc.4: `clud-bug configure-github` flags.
84
84
  // --dry-run prints the diff but skips PATCH; --branch overrides "main".
85
+ // --preset selects the vendored ruleset variant (default: skdd).
85
86
  dryRun: false,
86
87
  branch: null,
88
+ preset: null,
87
89
  };
88
90
  for (let i = 0; i < argv.length; i++) {
89
91
  const a = argv[i];
@@ -112,6 +114,7 @@ function parseArgs(argv) {
112
114
  else if (a === '--local-only') args.localOnly = true;
113
115
  else if (a === '--dry-run') args.dryRun = true;
114
116
  else if (a === '--branch') args.branch = argv[++i];
117
+ else if (a === '--preset') args.preset = argv[++i];
115
118
  else if (a === '--trigger') args.trigger = argv[++i];
116
119
  else if (a === '--diff-size') args.diffSizeBytes = Number(argv[++i]);
117
120
  // H3: `clud-bug post-check-run` flags.
@@ -123,6 +126,8 @@ function parseArgs(argv) {
123
126
  else if (a === '--no-strict') args.strict = false;
124
127
  else if (a === '--owner') args.owner = argv[++i];
125
128
  else if (a === '--details-url') args.detailsUrl = argv[++i];
129
+ // Phase Z: notary attestation bundle (JSON path) for `post-check-run`.
130
+ else if (a === '--bundle') args.bundle = argv[++i];
126
131
  else args._.push(a);
127
132
  }
128
133
  return args;
@@ -145,11 +150,14 @@ Commands:
145
150
  update Re-render workflows + refresh baseline specimens to the latest shipped
146
151
  templates. Custom and skills.sh-installed specimens left alone.
147
152
  configure-github <owner>/<repo>
148
- Apply the SPEC §7 canonical branch protection ruleset to a repo.
149
- Auth: GITHUB_TOKEN env first, then \`gh auth token\`. Use
150
- --dry-run to print the diff without PATCH-ing; --branch to
151
- target a non-main branch. Idempotent — already-canonical
152
- repos exit 0 with no changes.
153
+ One-stop repo setup: repo conveniences (squash-only merges,
154
+ auto-merge, delete-branch-on-merge, PR title/body squash
155
+ message ALL presets) + the SPEC §7 canonical branch
156
+ ruleset. \`--preset baseline|clud-bug|skdd|public-guard\`
157
+ picks the variant (default: skdd). Auth: GITHUB_TOKEN env
158
+ first, then \`gh auth token\`. Use --dry-run to print the
159
+ diff without writing; --branch to target a non-main branch.
160
+ Idempotent — already-canonical repos exit 0 with no changes.
153
161
  edit-workflow Helper for editing .github/workflows/clud-bug-*.yml in an isolated
154
162
  PR (the action refuses to review PRs that modify its own workflow).
155
163
  usage Read recent clud-bug-review run JSON + normalize cost per LOC.
@@ -223,6 +231,10 @@ Commands:
223
231
  clean|critical|failed --sha <sha> [--critical-count N]
224
232
  [--source local|ci] [--strict|--no-strict] [--dry-run].
225
233
  clean→success, critical+strict→failure, else neutral.
234
+ With CLUD_BUG_NOTARY_URL set + --bundle <file>, submits a
235
+ notary attestation bundle instead (Phase Z); the notary
236
+ issues the check. Falls back to the self-attested post if
237
+ the endpoint is unreachable.
226
238
 
227
239
  Options:
228
240
  --offline Skip skills.sh; pin only the bundled baseline specimens.
@@ -237,7 +249,7 @@ Options:
237
249
  \`git commit\` / \`logmind log\`, it fetches a review recipe and
238
250
  surfaces it to the agent (on this session's subscription)
239
251
  via asyncRewake. Implies --with-local-review. Off by default.
240
- --with-design (init) Install the design-critic kit (3 \`kind: design\`
252
+ --with-design (init) Install the design-critic kit (4 \`kind: design\`
241
253
  skills) and enable the off-by-default visual review
242
254
  lens — renders changed UI and critiques it. Off by default.
243
255
  --local-only (init) MAX MODE: install the slash command + commit hook
@@ -253,9 +265,11 @@ Options:
253
265
  required_conversation_resolution on the default
254
266
  branch (init only). Use for repos that manage
255
267
  branch protection via ruleset or org policy.
256
- --dry-run Print the canonical-v1 diff without PATCH-ing
268
+ --dry-run Print the canonical-v1 diff without writing
257
269
  (configure-github only).
258
270
  --branch <name> Target branch for configure-github (default: main).
271
+ --preset <name> configure-github ruleset preset: baseline | clud-bug |
272
+ skdd | public-guard (default: skdd).
259
273
  --trigger <ctx> review-prompt context: commit (default) | push | pr.
260
274
  --repo <owner/name> Restrict \`usage\` to a single repo. Default: all repos
261
275
  with clud-bug-review.yml in the gh user's auth scope.
@@ -1380,7 +1394,7 @@ async function runInit(args) {
1380
1394
  }
1381
1395
  }
1382
1396
 
1383
- // rc.16: --with-design installs the bundled design-critic kit (3 `kind:
1397
+ // rc.16: --with-design installs the bundled design-critic kit (4 `kind:
1384
1398
  // design` skills) and enables the off-by-default design lens. writeSkills
1385
1399
  // writes the SKILL.md files + merges the manifest entries; the `design` block
1386
1400
  // is flipped on the manifest read below so the local recipe + hosted bot both
@@ -1498,6 +1512,13 @@ async function runInit(args) {
1498
1512
  }
1499
1513
  log(' • Opt out by setting "strictMode": false in .claude/skills/.clud-bug.json.');
1500
1514
 
1515
+ // Phase M — a quiet, on-voice support nudge for the free tiers (max mode +
1516
+ // self-hosted Action run on the user's own resources; no metered charge fits).
1517
+ // The GitHub "Sponsor" button + the Stripe "coffee" link both live in FUNDING.yml.
1518
+ log('');
1519
+ log('clud-bug forages on your own subscription — free, and meant to stay that way.');
1520
+ log('If the specimens earn their keep, you can feed the bug: github.com/sponsors/thrillmade');
1521
+
1501
1522
  // v0.6.33 — opt-in unified install (mirror of logmind v0.6.8). When
1502
1523
  // --with-skdd is passed, subprocess to `pip install logmind` + `logmind init`
1503
1524
  // so Node-first users get the same one-command bootstrap as Python-first
@@ -1967,13 +1988,14 @@ async function runUpdateCmd(_args) {
1967
1988
  // SPEC §7 canonical ruleset applier from src/cli/configure-github.ts;
1968
1989
  // thin wrapper here just maps CLI args into the typed entry point. The
1969
1990
  // command is idempotent — re-runs on a converged repo exit 0 with no
1970
- // PATCH calls. See `src/core/configure-github.ts` for the diff + rule
1991
+ // create/update calls. See `src/core/configure-github.ts` for the diff + rule
1971
1992
  // table.
1972
1993
  async function runConfigureGithubCmd(args) {
1973
1994
  const { runConfigureGithub } = await import('./configure-github.js');
1974
1995
  const target = args._[1] ?? null;
1975
1996
  const code = await runConfigureGithub({
1976
1997
  target,
1998
+ preset: args.preset || undefined,
1977
1999
  branch: args.branch || 'main',
1978
2000
  dryRun: Boolean(args.dryRun),
1979
2001
  quiet: QUIET,
@@ -3,7 +3,7 @@
3
3
  // merge on those surfaces too (the hosted bot already posts it).
4
4
  //
5
5
  // Usage:
6
- // clud-bug post-check-run --sha <sha> --verdict clean|critical|failed \
6
+ // clud-bug post-check-run --sha <sha> --verdict clean|critical|failed|unverified \
7
7
  // [--critical-count N] [--source local|ci] [--strict|--no-strict] \
8
8
  // [--owner O --repo R] [--details-url URL] [--dry-run]
9
9
  //
@@ -14,8 +14,20 @@
14
14
 
15
15
  import { join } from 'node:path';
16
16
  import { spawnSync } from 'node:child_process';
17
+ import { readFile } from 'node:fs/promises';
17
18
 
18
- import { deriveCheck, normalizeVerdict, CLUD_BUG_CHECK_NAME } from '../core/index.js';
19
+ import {
20
+ deriveCheck,
21
+ normalizeVerdict,
22
+ CLUD_BUG_CHECK_NAME,
23
+ parseBundle,
24
+ validateBundle,
25
+ validateConsistency,
26
+ splitUnifiedDiff,
27
+ notaryResponseIsRejection,
28
+ type NotaryBundle,
29
+ type DiffFile,
30
+ } from '../core/index.js';
19
31
  import { readManifest } from './skills.js';
20
32
 
21
33
  interface PostCheckRunArgs {
@@ -27,6 +39,8 @@ interface PostCheckRunArgs {
27
39
  owner?: string;
28
40
  repo?: string;
29
41
  detailsUrl?: string;
42
+ /** Path to a notary attestation bundle (JSON). Activates the notary submit path. */
43
+ bundle?: string;
30
44
  dryRun?: boolean;
31
45
  cwd?: string;
32
46
  _?: string[];
@@ -37,6 +51,115 @@ function sh(cmd: string, cmdArgs: string[], input?: string): { ok: boolean; out:
37
51
  return { ok: r.status === 0, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() };
38
52
  }
39
53
 
54
+ /**
55
+ * Best-effort load of the diff a bundle attests to, as `DiffFile[]`, for the
56
+ * LOCAL pre-check. Prefers the PR diff (matches GitHub's view); falls back to
57
+ * the commit diff. Returns `[]` when neither is obtainable — the caller then
58
+ * skips the diff-dependent checks (③④) and lets the SERVER do the authoritative
59
+ * validation against GitHub's ground truth (Z4).
60
+ */
61
+ function loadDiffFiles(bundle: NotaryBundle): DiffFile[] {
62
+ let raw = '';
63
+ if (bundle.pr !== undefined) {
64
+ const r = sh('gh', ['pr', 'diff', String(bundle.pr), '--color', 'never']);
65
+ if (r.ok) raw = r.out;
66
+ }
67
+ if (!raw && bundle.head_sha) {
68
+ // -c core.quotepath=false → git emits non-ASCII paths as literal UTF-8
69
+ // (no octal quoting), so the splitter sees real filenames.
70
+ const r = sh('git', ['-c', 'core.quotepath=false', 'show', '--no-color', '--format=', bundle.head_sha]);
71
+ if (r.ok) raw = r.out;
72
+ }
73
+ return raw ? splitUnifiedDiff(raw) : [];
74
+ }
75
+
76
+ type NotaryOutcome = 'posted' | 'rejected' | 'fallback';
77
+
78
+ interface NotaryResult {
79
+ outcome: NotaryOutcome;
80
+ /** The parsed + locally-validated bundle, when we got that far (for a bundle-derived fallback). */
81
+ bundle: NotaryBundle | null;
82
+ }
83
+
84
+ /**
85
+ * The notary submit path (Phase Z). Reads + parses the bundle, LOCALLY
86
+ * re-validates it (the handshake — a deterministic program refusing to certify
87
+ * an inconsistent/ungrounded review), then POSTs to the notary. The server (Z4)
88
+ * re-validates ①–⑤ against GitHub and — as SOLE issuer — posts the pinned check.
89
+ *
90
+ * Outcomes:
91
+ * 'posted' — the notary accepted; the SERVER owns the check, do not self-post.
92
+ * 'rejected' — the certification is definitively refused (malformed / inconsistent /
93
+ * ungrounded bundle, OR a server 4xx AUTHORITATIVELY declining). Post
94
+ * NO check — never a false green off a bad artifact or over a server "no".
95
+ * 'fallback' — the endpoint is DOWN (network error or 5xx), not a verdict; the caller
96
+ * may self-post the self-attested check (derived from THIS bundle) so local
97
+ * max mode keeps gating while Z4 is pending.
98
+ */
99
+ async function submitToNotary(
100
+ notaryUrl: string,
101
+ bundlePath: string,
102
+ warn: (m: string) => void,
103
+ ): Promise<NotaryResult> {
104
+ let bundle: NotaryBundle | null;
105
+ try {
106
+ bundle = parseBundle(JSON.parse(await readFile(bundlePath, 'utf8')));
107
+ } catch (e) {
108
+ warn(`could not read the bundle at ${bundlePath} (${e instanceof Error ? e.message : String(e)}); not certifying.`);
109
+ return { outcome: 'rejected', bundle: null };
110
+ }
111
+ if (!bundle) {
112
+ warn(`the bundle at ${bundlePath} is malformed; not certifying (fix the review artifact).`);
113
+ return { outcome: 'rejected', bundle: null };
114
+ }
115
+
116
+ // Local pre-check: consistency is diff-free (always run); coverage + grounding
117
+ // need the diff (run only when one is obtainable — else defer to the server).
118
+ const diffFiles = loadDiffFiles(bundle);
119
+ const consistency = validateConsistency(bundle.verdict, bundle.findings);
120
+ if (!consistency.ok) {
121
+ warn(`bundle is internally inconsistent — ${consistency.reason}; not certifying.`);
122
+ return { outcome: 'rejected', bundle };
123
+ }
124
+ if (diffFiles.length > 0) {
125
+ const v = validateBundle(bundle, diffFiles);
126
+ if (!v.coverage.ok) {
127
+ warn(`bundle coverage is incomplete — unreviewed changed file(s): ${v.coverage.missingFiles.join(', ')}; not certifying.`);
128
+ return { outcome: 'rejected', bundle };
129
+ }
130
+ if (!v.grounding.ok) {
131
+ warn(`bundle has ungrounded critical finding(s): ${v.grounding.violations.map((x) => x.reason).join('; ')}; not certifying.`);
132
+ return { outcome: 'rejected', bundle };
133
+ }
134
+ }
135
+
136
+ // Submit. TODO(Z4): the server mints/consumes the nonce, re-fetches GitHub's
137
+ // ground-truth diff, re-runs ①–⑤, signs, and posts the pinned check.
138
+ const url = notaryUrl.replace(/\/+$/, '') + '/notarize';
139
+ try {
140
+ const res = await fetch(url, {
141
+ method: 'POST',
142
+ headers: { 'content-type': 'application/json' },
143
+ body: JSON.stringify(bundle),
144
+ });
145
+ if (res.ok) {
146
+ process.stdout.write(`clud-bug: notarized ${bundle.repo}@${bundle.head_sha.slice(0, 12)} (verdict=${bundle.verdict}); the notary posts the check.\n`);
147
+ return { outcome: 'posted', bundle };
148
+ }
149
+ // A 4xx is the SOLE issuer authoritatively declining — terminal, no check.
150
+ // Only a 5xx (server down) is a fallback-able outage.
151
+ if (notaryResponseIsRejection(res.status)) {
152
+ warn(`notary declined the bundle (HTTP ${res.status}); not certifying.`);
153
+ return { outcome: 'rejected', bundle };
154
+ }
155
+ warn(`notary unavailable (HTTP ${res.status}); falling back to the self-attested check.`);
156
+ return { outcome: 'fallback', bundle };
157
+ } catch (e) {
158
+ warn(`notary endpoint unreachable (${e instanceof Error ? e.message : String(e)}); falling back to the self-attested check.`);
159
+ return { outcome: 'fallback', bundle };
160
+ }
161
+ }
162
+
40
163
  export async function runPostCheckRun(args: PostCheckRunArgs): Promise<void> {
41
164
  const cwd = args.cwd ?? process.cwd();
42
165
  const warn = (m: string) => process.stderr.write(`clud-bug post-check-run: ${m}\n`);
@@ -49,6 +172,21 @@ export async function runPostCheckRun(args: PostCheckRunArgs): Promise<void> {
49
172
  sha = r.out;
50
173
  }
51
174
 
175
+ // --- Notary submit path (Phase Z) — the un-forgeable route -------------
176
+ // When CLUD_BUG_NOTARY_URL is set and a --bundle is supplied, submit an
177
+ // attestation bundle: the CLI locally re-checks it (the handshake) and the
178
+ // notary (Z4) issues the pinned check. Only 'fallback' (endpoint unreachable)
179
+ // continues to the self-attested self-post below; 'posted'/'rejected' are terminal.
180
+ let fallbackBundle: NotaryBundle | null = null;
181
+ const notaryUrl = process.env.CLUD_BUG_NOTARY_URL?.trim();
182
+ if (notaryUrl && typeof args.bundle === 'string' && args.bundle && !args.dryRun) {
183
+ const { outcome, bundle } = await submitToNotary(notaryUrl, args.bundle, warn);
184
+ if (outcome !== 'fallback') return;
185
+ // Endpoint down → self-post below, derived from the ALREADY-VALIDATED bundle
186
+ // (not the raw --verdict flags, which a bundle-only invocation never passes).
187
+ fallbackBundle = bundle;
188
+ }
189
+
52
190
  // --- strictMode: explicit flag wins, else the repo manifest -----------
53
191
  let strictMode = false;
54
192
  if (typeof args.strict === 'boolean') {
@@ -62,9 +200,15 @@ export async function runPostCheckRun(args: PostCheckRunArgs): Promise<void> {
62
200
  }
63
201
  }
64
202
 
65
- const verdict = normalizeVerdict(typeof args.verdict === 'string' ? args.verdict : undefined);
66
- const criticalCount = Number(args.criticalCount ?? 0) || 0;
67
- const source = args.source === 'local' ? 'local' : 'ci';
203
+ // A bundle-fallback self-post reflects what was actually VALIDATED (bundle
204
+ // verdict + its critical count, local source); otherwise the raw flags.
205
+ const verdict = fallbackBundle
206
+ ? fallbackBundle.verdict
207
+ : normalizeVerdict(typeof args.verdict === 'string' ? args.verdict : undefined);
208
+ const criticalCount = fallbackBundle
209
+ ? fallbackBundle.findings.filter((f) => f.severity === 'critical').length
210
+ : Number(args.criticalCount ?? 0) || 0;
211
+ const source: 'local' | 'ci' = fallbackBundle ? 'local' : args.source === 'local' ? 'local' : 'ci';
68
212
  const { conclusion, title, summary } = deriveCheck({ verdict, strictMode, criticalCount, source });
69
213
 
70
214
  // --- resolve owner/repo (flags, else gh) ------------------------------