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

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 (147) hide show
  1. package/README.md +20 -0
  2. package/data/canonical-v1.json +37 -0
  3. package/dist/cli/configure-github.d.ts +58 -0
  4. package/dist/cli/configure-github.d.ts.map +1 -0
  5. package/dist/cli/configure-github.js +242 -0
  6. package/dist/cli/configure-github.js.map +1 -0
  7. package/dist/cli/hooks.d.ts +51 -0
  8. package/dist/cli/hooks.d.ts.map +1 -0
  9. package/dist/cli/hooks.js +138 -0
  10. package/dist/cli/hooks.js.map +1 -0
  11. package/dist/cli/index.d.ts +1 -0
  12. package/dist/cli/index.d.ts.map +1 -1
  13. package/dist/cli/index.js +1 -0
  14. package/dist/cli/index.js.map +1 -1
  15. package/dist/cli/main.d.ts.map +1 -1
  16. package/dist/cli/main.js +949 -35
  17. package/dist/cli/main.js.map +1 -1
  18. package/dist/cli/post-check-run.d.ts +16 -0
  19. package/dist/cli/post-check-run.d.ts.map +1 -0
  20. package/dist/cli/post-check-run.js +93 -0
  21. package/dist/cli/post-check-run.js.map +1 -0
  22. package/dist/cli/review-prompt.d.ts +43 -0
  23. package/dist/cli/review-prompt.d.ts.map +1 -0
  24. package/dist/cli/review-prompt.js +287 -0
  25. package/dist/cli/review-prompt.js.map +1 -0
  26. package/dist/cli/skills.d.ts +7 -0
  27. package/dist/cli/skills.d.ts.map +1 -1
  28. package/dist/cli/skills.js +42 -2
  29. package/dist/cli/skills.js.map +1 -1
  30. package/dist/cli/update-notifier.d.ts +17 -0
  31. package/dist/cli/update-notifier.d.ts.map +1 -0
  32. package/dist/cli/update-notifier.js +88 -0
  33. package/dist/cli/update-notifier.js.map +1 -0
  34. package/dist/cli/update.d.ts.map +1 -1
  35. package/dist/cli/update.js +60 -11
  36. package/dist/cli/update.js.map +1 -1
  37. package/dist/core/auto-resolve.d.ts +235 -0
  38. package/dist/core/auto-resolve.d.ts.map +1 -0
  39. package/dist/core/auto-resolve.js +293 -0
  40. package/dist/core/auto-resolve.js.map +1 -0
  41. package/dist/core/budget-plan.d.ts +125 -0
  42. package/dist/core/budget-plan.d.ts.map +1 -0
  43. package/dist/core/budget-plan.js +211 -0
  44. package/dist/core/budget-plan.js.map +1 -0
  45. package/dist/core/check-verdict.d.ts +31 -0
  46. package/dist/core/check-verdict.d.ts.map +1 -0
  47. package/dist/core/check-verdict.js +66 -0
  48. package/dist/core/check-verdict.js.map +1 -0
  49. package/dist/core/configure-github.d.ts +179 -0
  50. package/dist/core/configure-github.d.ts.map +1 -0
  51. package/dist/core/configure-github.js +289 -0
  52. package/dist/core/configure-github.js.map +1 -0
  53. package/dist/core/design.d.ts +37 -0
  54. package/dist/core/design.d.ts.map +1 -0
  55. package/dist/core/design.js +47 -0
  56. package/dist/core/design.js.map +1 -0
  57. package/dist/core/diff-findings.d.ts +96 -0
  58. package/dist/core/diff-findings.d.ts.map +1 -0
  59. package/dist/core/diff-findings.js +261 -0
  60. package/dist/core/diff-findings.js.map +1 -0
  61. package/dist/core/formal-review.d.ts +79 -0
  62. package/dist/core/formal-review.d.ts.map +1 -0
  63. package/dist/core/formal-review.js +71 -0
  64. package/dist/core/formal-review.js.map +1 -0
  65. package/dist/core/index.d.ts +15 -2
  66. package/dist/core/index.d.ts.map +1 -1
  67. package/dist/core/index.js +53 -1
  68. package/dist/core/index.js.map +1 -1
  69. package/dist/core/inline-threads.d.ts +160 -0
  70. package/dist/core/inline-threads.d.ts.map +1 -0
  71. package/dist/core/inline-threads.js +369 -0
  72. package/dist/core/inline-threads.js.map +1 -0
  73. package/dist/core/multi-pass-aggregate.d.ts +161 -0
  74. package/dist/core/multi-pass-aggregate.d.ts.map +1 -0
  75. package/dist/core/multi-pass-aggregate.js +457 -0
  76. package/dist/core/multi-pass-aggregate.js.map +1 -0
  77. package/dist/core/plan-review.d.ts +57 -0
  78. package/dist/core/plan-review.d.ts.map +1 -0
  79. package/dist/core/plan-review.js +77 -0
  80. package/dist/core/plan-review.js.map +1 -0
  81. package/dist/core/prompt-builder.d.ts +18 -0
  82. package/dist/core/prompt-builder.d.ts.map +1 -1
  83. package/dist/core/prompt-builder.js +24 -2
  84. package/dist/core/prompt-builder.js.map +1 -1
  85. package/dist/core/render.d.ts.map +1 -1
  86. package/dist/core/render.js +1 -19
  87. package/dist/core/render.js.map +1 -1
  88. package/dist/core/resolve-verifier.d.ts +53 -0
  89. package/dist/core/resolve-verifier.d.ts.map +1 -0
  90. package/dist/core/resolve-verifier.js +204 -0
  91. package/dist/core/resolve-verifier.js.map +1 -0
  92. package/dist/core/review-context.d.ts +39 -0
  93. package/dist/core/review-context.d.ts.map +1 -0
  94. package/dist/core/review-context.js +99 -0
  95. package/dist/core/review-context.js.map +1 -0
  96. package/dist/core/review-plan.d.ts +219 -0
  97. package/dist/core/review-plan.d.ts.map +1 -0
  98. package/dist/core/review-plan.js +274 -0
  99. package/dist/core/review-plan.js.map +1 -0
  100. package/dist/core/review-writeback.d.ts +74 -0
  101. package/dist/core/review-writeback.d.ts.map +1 -1
  102. package/dist/core/review-writeback.js +71 -4
  103. package/dist/core/review-writeback.js.map +1 -1
  104. package/dist/core/skills.d.ts +25 -0
  105. package/dist/core/skills.d.ts.map +1 -1
  106. package/dist/core/skills.js +100 -4
  107. package/dist/core/skills.js.map +1 -1
  108. package/dist/core/version.d.ts +2 -0
  109. package/dist/core/version.d.ts.map +1 -0
  110. package/dist/core/version.js +4 -0
  111. package/dist/core/version.js.map +1 -0
  112. package/package.json +11 -5
  113. package/src/cli/configure-github.ts +329 -0
  114. package/src/cli/hooks.ts +155 -0
  115. package/src/cli/index.ts +6 -0
  116. package/src/cli/main.ts +1031 -37
  117. package/src/cli/post-check-run.ts +115 -0
  118. package/src/cli/review-prompt.ts +355 -0
  119. package/src/cli/skills.ts +42 -2
  120. package/src/cli/update-notifier.ts +100 -0
  121. package/src/cli/update.ts +60 -11
  122. package/src/core/auto-resolve.ts +488 -0
  123. package/src/core/budget-plan.ts +324 -0
  124. package/src/core/check-verdict.ts +93 -0
  125. package/src/core/configure-github.ts +497 -0
  126. package/src/core/design.ts +73 -0
  127. package/src/core/diff-findings.ts +323 -0
  128. package/src/core/formal-review.ts +168 -0
  129. package/src/core/index.ts +188 -0
  130. package/src/core/inline-threads.ts +471 -0
  131. package/src/core/multi-pass-aggregate.ts +591 -0
  132. package/src/core/plan-review.ts +136 -0
  133. package/src/core/prompt-builder.ts +44 -2
  134. package/src/core/render.ts +10 -16
  135. package/src/core/resolve-verifier.ts +254 -0
  136. package/src/core/review-context.ts +106 -0
  137. package/src/core/review-plan.ts +485 -0
  138. package/src/core/review-writeback.ts +161 -4
  139. package/src/core/skills.ts +128 -5
  140. package/src/core/version.ts +3 -0
  141. package/templates/clud-bug-review.md.tmpl +115 -0
  142. package/templates/skills/design/design-system-consistency.md +41 -0
  143. package/templates/skills/design/frontend-a11y.md +41 -0
  144. package/templates/skills/design/visual-polish.md +41 -0
  145. package/templates/workflow-py.yml.tmpl +92 -2
  146. package/templates/workflow-ts.yml.tmpl +92 -2
  147. package/templates/workflow.yml.tmpl +173 -2
@@ -0,0 +1,497 @@
1
+ // SPEC §7 canonical-ruleset applier — pure core half of `configure-github`.
2
+ //
3
+ // External users installing the App expect "best practice branch protection"
4
+ // applied automatically. This module diffs the current GitHub state against
5
+ // the canonical ruleset (bundled at `data/canonical-v1.json`) and emits the
6
+ // minimal set of PATCH calls to converge.
7
+ //
8
+ // Architectural shape mirrors `formal-review.ts` and `review-writeback.ts`:
9
+ // the pure rule-table + diff logic lives in core (no Octokit dep at compile
10
+ // time — clud-bug-app already has `@octokit/rest` and passes its instance;
11
+ // the CLI side wraps `gh api` in a tiny adapter that satisfies the same
12
+ // structural interface). Core stays npm-side single-source-of-truth per the
13
+ // Bug 9 / Phase 2-4 architectural lock.
14
+ //
15
+ // Idempotent contract (HARD GUARANTEE):
16
+ //
17
+ // const a = await applyCanonicalRuleset(octokit, params);
18
+ // const b = await applyCanonicalRuleset(octokit, params);
19
+ // // b.alreadyCanonical === true; b.changes.length === 0
20
+ //
21
+ // A second `apply()` call against a freshly-converged repo MUST produce
22
+ // `alreadyCanonical: true` with `changes: []` and zero PATCH calls. This
23
+ // lets external automation (CI, dispatch loops, dashboard probes) call
24
+ // `apply()` defensively without rate-limiting itself out of the API.
25
+ //
26
+ // SPEC pins honored here:
27
+ // - canonical-v1.json schema (frozen at v1; major bumps require coordinated
28
+ // tool releases per the `$comment` field).
29
+ // - `required_status_checks.contexts` are TREATED AS A SUPERSET: if the
30
+ // repo already requires MORE contexts than the canonical list, we leave
31
+ // them alone (a repo that runs additional CI gates legitimately needs
32
+ // them in the required set). We only ADD the canonical contexts that
33
+ // are missing.
34
+ // - `required_approving_review_count`: canonical floor is 1. If the repo
35
+ // already requires MORE than 1, we leave it alone (only raise to floor).
36
+ //
37
+ // Offline-resilient bundling: the canonical ruleset ships in `data/` at
38
+ // the package root (alongside `bin/`, `templates/`, etc., per package.json
39
+ // `files`). We read it at runtime via fs.readFile so the build stays out
40
+ // of TypeScript's `rootDir` constraint, and so callers that re-bundle
41
+ // clud-bug into a single file can override the ruleset by passing one
42
+ // explicitly to `applyCanonicalRuleset({ ruleset })`.
43
+
44
+ import { readFile } from 'node:fs/promises';
45
+ import { dirname, join } from 'node:path';
46
+ import { fileURLToPath } from 'node:url';
47
+
48
+ /**
49
+ * The canonical-v1.json structure. Frozen at v1; major bumps require
50
+ * coordinated tool releases per the protocol repo's `$comment` field.
51
+ */
52
+ export interface CanonicalRuleset {
53
+ version: 'v1';
54
+ spec_version: string;
55
+ branch_protection: {
56
+ required_status_checks: {
57
+ strict: boolean;
58
+ contexts: string[];
59
+ };
60
+ required_pull_request_reviews: {
61
+ required_approving_review_count: number;
62
+ dismiss_stale_reviews: boolean;
63
+ require_code_owner_reviews: boolean;
64
+ };
65
+ required_conversation_resolution: boolean;
66
+ enforce_admins: boolean;
67
+ allow_force_pushes: boolean;
68
+ allow_deletions: boolean;
69
+ required_linear_history: boolean;
70
+ allow_auto_merge: boolean;
71
+ delete_branch_on_merge: boolean;
72
+ squash_merge_commit_title: string;
73
+ squash_merge_commit_message: string;
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Resolves the package root from this file's URL. After tsc the file lives
79
+ * at `<pkg>/dist/core/configure-github.js`; three dirname() climbs land on
80
+ * `<pkg>`, parallel to the package.json `files` map entry for `data`.
81
+ */
82
+ const PKG_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
83
+
84
+ /** Memoized canonical ruleset — loaded once per process. */
85
+ let CANONICAL_V1_CACHE: CanonicalRuleset | null = null;
86
+
87
+ /**
88
+ * Loads the bundled canonical-v1 ruleset from `data/canonical-v1.json`.
89
+ * Memoized so repeated calls don't re-hit the filesystem.
90
+ *
91
+ * Throws a wrapped error if the JSON file is missing — that's an
92
+ * install-time defect (package.json `files` excluded `data/`), not a
93
+ * runtime concern, and the loud failure surfaces it immediately.
94
+ */
95
+ export async function loadCanonicalV1(): Promise<CanonicalRuleset> {
96
+ if (CANONICAL_V1_CACHE) return CANONICAL_V1_CACHE;
97
+ const path = join(PKG_ROOT, 'data', 'canonical-v1.json');
98
+ try {
99
+ const raw = await readFile(path, 'utf-8');
100
+ CANONICAL_V1_CACHE = JSON.parse(raw) as CanonicalRuleset;
101
+ return CANONICAL_V1_CACHE;
102
+ } catch (err) {
103
+ const msg = err instanceof Error ? err.message : String(err);
104
+ throw new Error(
105
+ `configure-github: failed to load bundled canonical-v1.json at ${path}: ${msg}`,
106
+ );
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Minimal structural Octokit interface — only the methods we actually call.
112
+ *
113
+ * We intentionally don't import `@octokit/rest` types: that would force
114
+ * every consumer (App, CLI, future tools) to install Octokit at runtime
115
+ * even when they pass a `gh`-CLI-backed adapter. Keeping the shape
116
+ * structural lets the CLI's `gh`-wrapping adapter satisfy it without
117
+ * pulling in a 200KB dep.
118
+ *
119
+ * If `@octokit/rest`'s real method shapes ever drift, the adapter +
120
+ * runtime call sites are the only files that need updating; the rule
121
+ * table here stays Octokit-version-agnostic.
122
+ */
123
+ export interface OctokitLike {
124
+ repos: {
125
+ /** Read branch protection state. Throws on 404 (no protection rule). */
126
+ getBranchProtection(params: {
127
+ owner: string;
128
+ repo: string;
129
+ branch: string;
130
+ }): Promise<{
131
+ data: {
132
+ required_status_checks?:
133
+ | { strict?: boolean; contexts?: string[] }
134
+ | null;
135
+ required_pull_request_reviews?:
136
+ | {
137
+ required_approving_review_count?: number;
138
+ dismiss_stale_reviews?: boolean;
139
+ require_code_owner_reviews?: boolean;
140
+ }
141
+ | null;
142
+ required_conversation_resolution?: { enabled?: boolean } | null;
143
+ enforce_admins?: { enabled?: boolean } | null;
144
+ allow_force_pushes?: { enabled?: boolean } | null;
145
+ allow_deletions?: { enabled?: boolean } | null;
146
+ required_linear_history?: { enabled?: boolean } | null;
147
+ };
148
+ }>;
149
+ /** PUT branch protection (the REST API does NOT support PATCH). */
150
+ updateBranchProtection(params: {
151
+ owner: string;
152
+ repo: string;
153
+ branch: string;
154
+ required_status_checks: { strict: boolean; contexts: string[] } | null;
155
+ enforce_admins: boolean | null;
156
+ required_pull_request_reviews: {
157
+ required_approving_review_count: number;
158
+ dismiss_stale_reviews: boolean;
159
+ require_code_owner_reviews: boolean;
160
+ } | null;
161
+ restrictions: null;
162
+ required_conversation_resolution?: boolean;
163
+ required_linear_history?: boolean;
164
+ allow_force_pushes?: boolean;
165
+ allow_deletions?: boolean;
166
+ }): Promise<unknown>;
167
+ /** Read repo-level settings (delete_branch_on_merge, allow_auto_merge, etc). */
168
+ get(params: { owner: string; repo: string }): Promise<{
169
+ data: {
170
+ delete_branch_on_merge?: boolean;
171
+ allow_auto_merge?: boolean;
172
+ squash_merge_commit_title?: string;
173
+ squash_merge_commit_message?: string;
174
+ };
175
+ }>;
176
+ /** PATCH repo-level settings. */
177
+ update(params: {
178
+ owner: string;
179
+ repo: string;
180
+ delete_branch_on_merge?: boolean;
181
+ allow_auto_merge?: boolean;
182
+ squash_merge_commit_title?: string;
183
+ squash_merge_commit_message?: string;
184
+ }): Promise<unknown>;
185
+ };
186
+ }
187
+
188
+ export interface ApplyCanonicalRulesetParams {
189
+ owner: string;
190
+ repo: string;
191
+ /**
192
+ * Target branch (default: `main`). Most consumers will use `main`; this
193
+ * is exposed for downstream tools that converge non-`main` defaults.
194
+ */
195
+ branch?: string;
196
+ /**
197
+ * The ruleset to apply. Defaults to the bundled `canonical-v1.json`
198
+ * (loaded via `loadCanonicalV1()` if absent). Future v2 callers pass
199
+ * an explicit ruleset; the type system pins the shape.
200
+ */
201
+ ruleset?: CanonicalRuleset;
202
+ /**
203
+ * If true, compute diff only and skip all PATCH calls. The result still
204
+ * reports `changes: string[]` so the CLI can render the human-readable
205
+ * diff before applying.
206
+ */
207
+ dryRun?: boolean;
208
+ }
209
+
210
+ export interface ApplyResult {
211
+ /** Human-readable diff lines, one per detected difference. Empty when alreadyCanonical. */
212
+ changes: string[];
213
+ /** True when the live state already matches every canonical setting. */
214
+ alreadyCanonical: boolean;
215
+ /** Schema version applied (always 'canonical-v1' for this build). */
216
+ ruleset: 'canonical-v1';
217
+ }
218
+
219
+ /**
220
+ * Applies the canonical ruleset to a GitHub repo. Reads current state via
221
+ * the Octokit-like instance, diffs against `ruleset` (default
222
+ * `CANONICAL_V1`), and PATCHes only what differs. Idempotent: second call
223
+ * returns `alreadyCanonical: true` with no PATCH side effects.
224
+ *
225
+ * Behavior on partial mismatch:
226
+ *
227
+ * - `required_status_checks.contexts`: canonical contexts that are
228
+ * missing get added; extra contexts on the repo are preserved (superset
229
+ * contract — a repo that runs more CI gates legitimately needs them).
230
+ * - `required_approving_review_count`: canonical floor is 1; if the repo
231
+ * already requires more, we don't lower (raise-only contract).
232
+ * - All booleans (allow_force_pushes, allow_deletions, etc.): converge
233
+ * to the canonical value exactly.
234
+ *
235
+ * Throws on Octokit transport failure (auth, network, 403). The CLI
236
+ * wraps these with a friendly error message.
237
+ */
238
+ export async function applyCanonicalRuleset(
239
+ octokit: OctokitLike,
240
+ params: ApplyCanonicalRulesetParams,
241
+ ): Promise<ApplyResult> {
242
+ const {
243
+ owner,
244
+ repo,
245
+ branch = 'main',
246
+ dryRun = false,
247
+ } = params;
248
+ const ruleset = params.ruleset ?? (await loadCanonicalV1());
249
+
250
+ const target = ruleset.branch_protection;
251
+ const changes: string[] = [];
252
+
253
+ // ----- Read current branch protection state ---------------------------
254
+ // 404 → no base protection rule. We treat that as "every canonical
255
+ // setting differs" so the apply path creates the rule. Other errors
256
+ // bubble up — callers can't recover from a 403.
257
+ let current: Awaited<
258
+ ReturnType<OctokitLike['repos']['getBranchProtection']>
259
+ >['data'] = {};
260
+ let hasBaseProtection = true;
261
+ try {
262
+ const resp = await octokit.repos.getBranchProtection({
263
+ owner,
264
+ repo,
265
+ branch,
266
+ });
267
+ current = resp.data;
268
+ } catch (err) {
269
+ if (isBranchNotProtected(err)) {
270
+ hasBaseProtection = false;
271
+ } else {
272
+ throw err;
273
+ }
274
+ }
275
+
276
+ // ----- Diff: required_status_checks -----------------------------------
277
+ // Behavior: contexts are a SUPERSET. Strict mode must match exactly.
278
+ const currentChecks = current.required_status_checks ?? null;
279
+ const currentContexts = new Set(currentChecks?.contexts ?? []);
280
+ const targetContexts = new Set(target.required_status_checks.contexts);
281
+ const missingContexts = [...targetContexts].filter(
282
+ (c) => !currentContexts.has(c),
283
+ );
284
+ const strictMismatch =
285
+ (currentChecks?.strict ?? false) !== target.required_status_checks.strict;
286
+ let needsChecksPatch = !hasBaseProtection;
287
+ if (missingContexts.length > 0) {
288
+ changes.push(
289
+ `required_status_checks.contexts: add ${JSON.stringify(missingContexts)}`,
290
+ );
291
+ needsChecksPatch = true;
292
+ }
293
+ if (strictMismatch) {
294
+ changes.push(
295
+ `required_status_checks.strict: ${currentChecks?.strict ?? false} → ${target.required_status_checks.strict}`,
296
+ );
297
+ needsChecksPatch = true;
298
+ }
299
+ // The merged context list: keep extras the repo already required.
300
+ const mergedContexts = [
301
+ ...new Set([
302
+ ...(currentChecks?.contexts ?? []),
303
+ ...target.required_status_checks.contexts,
304
+ ]),
305
+ ];
306
+
307
+ // ----- Diff: required_pull_request_reviews ----------------------------
308
+ // Behavior: approving_review_count is a FLOOR (only raise). Booleans
309
+ // converge exactly.
310
+ const currentReviews = current.required_pull_request_reviews ?? null;
311
+ const currentCount = currentReviews?.required_approving_review_count ?? 0;
312
+ const targetCount =
313
+ target.required_pull_request_reviews.required_approving_review_count;
314
+ const effectiveCount = Math.max(currentCount, targetCount);
315
+ const targetDismiss =
316
+ target.required_pull_request_reviews.dismiss_stale_reviews;
317
+ const targetCodeOwner =
318
+ target.required_pull_request_reviews.require_code_owner_reviews;
319
+ let needsReviewsPatch = !hasBaseProtection;
320
+ if (effectiveCount !== currentCount) {
321
+ changes.push(
322
+ `required_pull_request_reviews.required_approving_review_count: ${currentCount} → ${effectiveCount}`,
323
+ );
324
+ needsReviewsPatch = true;
325
+ }
326
+ if ((currentReviews?.dismiss_stale_reviews ?? false) !== targetDismiss) {
327
+ changes.push(
328
+ `required_pull_request_reviews.dismiss_stale_reviews: ${currentReviews?.dismiss_stale_reviews ?? false} → ${targetDismiss}`,
329
+ );
330
+ needsReviewsPatch = true;
331
+ }
332
+ if (
333
+ (currentReviews?.require_code_owner_reviews ?? false) !== targetCodeOwner
334
+ ) {
335
+ changes.push(
336
+ `required_pull_request_reviews.require_code_owner_reviews: ${currentReviews?.require_code_owner_reviews ?? false} → ${targetCodeOwner}`,
337
+ );
338
+ needsReviewsPatch = true;
339
+ }
340
+
341
+ // ----- Diff: single-flag branch protection booleans -------------------
342
+ const conv =
343
+ current.required_conversation_resolution?.enabled ?? false;
344
+ const enforceAdmins = current.enforce_admins?.enabled ?? false;
345
+ const allowFP = current.allow_force_pushes?.enabled ?? false;
346
+ const allowDel = current.allow_deletions?.enabled ?? false;
347
+ const linearHistory = current.required_linear_history?.enabled ?? false;
348
+ if (conv !== target.required_conversation_resolution) {
349
+ changes.push(
350
+ `required_conversation_resolution: ${conv} → ${target.required_conversation_resolution}`,
351
+ );
352
+ }
353
+ if (enforceAdmins !== target.enforce_admins) {
354
+ changes.push(
355
+ `enforce_admins: ${enforceAdmins} → ${target.enforce_admins}`,
356
+ );
357
+ }
358
+ if (allowFP !== target.allow_force_pushes) {
359
+ changes.push(
360
+ `allow_force_pushes: ${allowFP} → ${target.allow_force_pushes}`,
361
+ );
362
+ }
363
+ if (allowDel !== target.allow_deletions) {
364
+ changes.push(
365
+ `allow_deletions: ${allowDel} → ${target.allow_deletions}`,
366
+ );
367
+ }
368
+ if (linearHistory !== target.required_linear_history) {
369
+ changes.push(
370
+ `required_linear_history: ${linearHistory} → ${target.required_linear_history}`,
371
+ );
372
+ }
373
+
374
+ // ----- Diff: repo-level settings --------------------------------------
375
+ // delete_branch_on_merge / allow_auto_merge / squash commit shape live
376
+ // on the REPO, not the branch protection rule. Fetched separately.
377
+ const repoResp = await octokit.repos.get({ owner, repo });
378
+ const repoData = repoResp.data;
379
+ const repoPatch: Parameters<OctokitLike['repos']['update']>[0] = {
380
+ owner,
381
+ repo,
382
+ };
383
+ let needsRepoPatch = false;
384
+ if (
385
+ (repoData.delete_branch_on_merge ?? false) !== target.delete_branch_on_merge
386
+ ) {
387
+ changes.push(
388
+ `delete_branch_on_merge: ${repoData.delete_branch_on_merge ?? false} → ${target.delete_branch_on_merge}`,
389
+ );
390
+ repoPatch.delete_branch_on_merge = target.delete_branch_on_merge;
391
+ needsRepoPatch = true;
392
+ }
393
+ if ((repoData.allow_auto_merge ?? false) !== target.allow_auto_merge) {
394
+ changes.push(
395
+ `allow_auto_merge: ${repoData.allow_auto_merge ?? false} → ${target.allow_auto_merge}`,
396
+ );
397
+ repoPatch.allow_auto_merge = target.allow_auto_merge;
398
+ needsRepoPatch = true;
399
+ }
400
+ if (
401
+ (repoData.squash_merge_commit_title ?? '') !==
402
+ target.squash_merge_commit_title
403
+ ) {
404
+ changes.push(
405
+ `squash_merge_commit_title: ${repoData.squash_merge_commit_title ?? '(unset)'} → ${target.squash_merge_commit_title}`,
406
+ );
407
+ repoPatch.squash_merge_commit_title = target.squash_merge_commit_title;
408
+ needsRepoPatch = true;
409
+ }
410
+ if (
411
+ (repoData.squash_merge_commit_message ?? '') !==
412
+ target.squash_merge_commit_message
413
+ ) {
414
+ changes.push(
415
+ `squash_merge_commit_message: ${repoData.squash_merge_commit_message ?? '(unset)'} → ${target.squash_merge_commit_message}`,
416
+ );
417
+ repoPatch.squash_merge_commit_message = target.squash_merge_commit_message;
418
+ needsRepoPatch = true;
419
+ }
420
+
421
+ const alreadyCanonical = changes.length === 0;
422
+
423
+ if (alreadyCanonical || dryRun) {
424
+ return {
425
+ changes,
426
+ alreadyCanonical,
427
+ ruleset: 'canonical-v1',
428
+ };
429
+ }
430
+
431
+ // ----- Apply: branch protection ---------------------------------------
432
+ // The REST API requires a PUT (not PATCH) with the full settings object.
433
+ // We always send a complete envelope; the diff above gates whether we
434
+ // call PUT at all.
435
+ if (needsChecksPatch || needsReviewsPatch || !hasBaseProtection || hasBranchProtectionLevelDiff(changes)) {
436
+ await octokit.repos.updateBranchProtection({
437
+ owner,
438
+ repo,
439
+ branch,
440
+ required_status_checks: {
441
+ strict: target.required_status_checks.strict,
442
+ contexts: mergedContexts,
443
+ },
444
+ enforce_admins: target.enforce_admins,
445
+ required_pull_request_reviews: {
446
+ required_approving_review_count: effectiveCount,
447
+ dismiss_stale_reviews: targetDismiss,
448
+ require_code_owner_reviews: targetCodeOwner,
449
+ },
450
+ restrictions: null,
451
+ required_conversation_resolution: target.required_conversation_resolution,
452
+ required_linear_history: target.required_linear_history,
453
+ allow_force_pushes: target.allow_force_pushes,
454
+ allow_deletions: target.allow_deletions,
455
+ });
456
+ }
457
+
458
+ // ----- Apply: repo-level settings -------------------------------------
459
+ if (needsRepoPatch) {
460
+ await octokit.repos.update(repoPatch);
461
+ }
462
+
463
+ return {
464
+ changes,
465
+ alreadyCanonical: false,
466
+ ruleset: 'canonical-v1',
467
+ };
468
+ }
469
+
470
+ /**
471
+ * Octokit (and gh's wrapping adapter) report a missing branch protection
472
+ * rule as a 404. We catch on the structural shape of the error so the
473
+ * downstream Octokit version doesn't matter.
474
+ */
475
+ function isBranchNotProtected(err: unknown): boolean {
476
+ if (!err || typeof err !== 'object') return false;
477
+ const e = err as { status?: number; message?: string };
478
+ if (e.status === 404) return true;
479
+ if (typeof e.message === 'string') {
480
+ return /Branch not protected|Not Found|404/i.test(e.message);
481
+ }
482
+ return false;
483
+ }
484
+
485
+ /**
486
+ * Detect whether any of the per-branch protection settings (conversation
487
+ * resolution / force-pushes / deletions / linear-history / enforce-admins)
488
+ * appeared in the change list. Used to decide whether the branch protection
489
+ * PUT needs to fire when status_checks + reviews are both in sync.
490
+ */
491
+ function hasBranchProtectionLevelDiff(changes: string[]): boolean {
492
+ return changes.some((c) =>
493
+ /^(required_conversation_resolution|enforce_admins|allow_force_pushes|allow_deletions|required_linear_history):/.test(
494
+ c,
495
+ ),
496
+ );
497
+ }
@@ -0,0 +1,73 @@
1
+ // Design-critic lens config + gate (Track B, rc.15).
2
+ //
3
+ // The design-critic is an OPTIONAL, off-by-default visual review pass that
4
+ // renders the changed UI (light + dark) and critiques it against `kind: design`
5
+ // skills. It is gated tightly so it only ever runs — and only ever costs — when
6
+ // a repo has explicitly opted in. This module is the shared, pure brain: the
7
+ // local recipe (`review-prompt`) and the hosted bot both resolve the config and
8
+ // the run-gate here so the policy can't fork (SPEC §11.5 / §12).
9
+
10
+ import type { ReviewTrigger } from './plan-review.js';
11
+
12
+ /** How design findings interact with the merge gate. */
13
+ export type DesignGate = 'advisory' | 'strict';
14
+
15
+ /** Resolved `.clud-bug.json` `design` block (defaults applied). */
16
+ export interface DesignConfig {
17
+ /** Master switch. Default OFF — the design pass never runs unless this is true. */
18
+ enabled: boolean;
19
+ /**
20
+ * `advisory` (default) — design findings post as comments, never block merge.
21
+ * `strict` — a design `critical` turns the check RED (opt-in).
22
+ */
23
+ gate: DesignGate;
24
+ /** Themes to render + critique. Default both. */
25
+ themes: string[];
26
+ /** Viewports to render. Default a single desktop viewport. */
27
+ viewports: string[];
28
+ }
29
+
30
+ /** Off-by-default builtin — the cost-control floor. */
31
+ export const BUILTIN_DESIGN_CONFIG: DesignConfig = {
32
+ enabled: false,
33
+ gate: 'advisory',
34
+ themes: ['light', 'dark'],
35
+ viewports: ['desktop'],
36
+ };
37
+
38
+ /**
39
+ * Read + normalize the `design` block from a parsed `.clud-bug.json` manifest.
40
+ * Tolerant: a missing/malformed block resolves to the off-by-default builtin,
41
+ * so a typo can never silently *enable* the (cost-bearing) pass.
42
+ */
43
+ export function readDesignConfig(manifest: unknown): DesignConfig {
44
+ const raw = (manifest as { design?: unknown } | null | undefined)?.design;
45
+ if (!raw || typeof raw !== 'object') return { ...BUILTIN_DESIGN_CONFIG };
46
+ const d = raw as Record<string, unknown>;
47
+ const strArr = (v: unknown, fallback: string[]): string[] =>
48
+ Array.isArray(v) && v.length > 0 ? v.map(String) : [...fallback];
49
+ return {
50
+ enabled: d['enabled'] === true,
51
+ gate: d['gate'] === 'strict' ? 'strict' : 'advisory',
52
+ themes: strArr(d['themes'], BUILTIN_DESIGN_CONFIG.themes),
53
+ viewports: strArr(d['viewports'], BUILTIN_DESIGN_CONFIG.viewports),
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Consumer-agnostic run-gate for the design-critic. Pure.
59
+ *
60
+ * True only when the repo opted in (`enabled`), at least one `kind: design`
61
+ * skill applies, and this is a PR-level review (the pass is too expensive for
62
+ * per-commit / per-push triggers). Consumers layer their own runtime
63
+ * preconditions on top: the local recipe defers the deploy-preview-URL check to
64
+ * the agent; the hosted bot additionally requires a paying tier + a resolved
65
+ * preview URL before it spends a render.
66
+ */
67
+ export function shouldRunDesign(
68
+ config: DesignConfig,
69
+ designSkillCount: number,
70
+ trigger: ReviewTrigger,
71
+ ): boolean {
72
+ return config.enabled && designSkillCount > 0 && trigger === 'pr';
73
+ }