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.
- package/README.md +20 -0
- package/data/canonical-v1.json +37 -0
- package/dist/cli/configure-github.d.ts +58 -0
- package/dist/cli/configure-github.d.ts.map +1 -0
- package/dist/cli/configure-github.js +242 -0
- package/dist/cli/configure-github.js.map +1 -0
- package/dist/cli/hooks.d.ts +51 -0
- package/dist/cli/hooks.d.ts.map +1 -0
- package/dist/cli/hooks.js +138 -0
- package/dist/cli/hooks.js.map +1 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +1 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +949 -35
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/post-check-run.d.ts +16 -0
- package/dist/cli/post-check-run.d.ts.map +1 -0
- package/dist/cli/post-check-run.js +93 -0
- package/dist/cli/post-check-run.js.map +1 -0
- package/dist/cli/review-prompt.d.ts +43 -0
- package/dist/cli/review-prompt.d.ts.map +1 -0
- package/dist/cli/review-prompt.js +287 -0
- package/dist/cli/review-prompt.js.map +1 -0
- package/dist/cli/skills.d.ts +7 -0
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +42 -2
- package/dist/cli/skills.js.map +1 -1
- package/dist/cli/update-notifier.d.ts +17 -0
- package/dist/cli/update-notifier.d.ts.map +1 -0
- package/dist/cli/update-notifier.js +88 -0
- package/dist/cli/update-notifier.js.map +1 -0
- package/dist/cli/update.d.ts.map +1 -1
- package/dist/cli/update.js +60 -11
- package/dist/cli/update.js.map +1 -1
- package/dist/core/auto-resolve.d.ts +235 -0
- package/dist/core/auto-resolve.d.ts.map +1 -0
- package/dist/core/auto-resolve.js +293 -0
- package/dist/core/auto-resolve.js.map +1 -0
- package/dist/core/budget-plan.d.ts +125 -0
- package/dist/core/budget-plan.d.ts.map +1 -0
- package/dist/core/budget-plan.js +211 -0
- package/dist/core/budget-plan.js.map +1 -0
- package/dist/core/check-verdict.d.ts +31 -0
- package/dist/core/check-verdict.d.ts.map +1 -0
- package/dist/core/check-verdict.js +66 -0
- package/dist/core/check-verdict.js.map +1 -0
- package/dist/core/configure-github.d.ts +179 -0
- package/dist/core/configure-github.d.ts.map +1 -0
- package/dist/core/configure-github.js +289 -0
- package/dist/core/configure-github.js.map +1 -0
- package/dist/core/design.d.ts +37 -0
- package/dist/core/design.d.ts.map +1 -0
- package/dist/core/design.js +47 -0
- package/dist/core/design.js.map +1 -0
- package/dist/core/diff-findings.d.ts +96 -0
- package/dist/core/diff-findings.d.ts.map +1 -0
- package/dist/core/diff-findings.js +261 -0
- package/dist/core/diff-findings.js.map +1 -0
- package/dist/core/formal-review.d.ts +79 -0
- package/dist/core/formal-review.d.ts.map +1 -0
- package/dist/core/formal-review.js +71 -0
- package/dist/core/formal-review.js.map +1 -0
- package/dist/core/index.d.ts +15 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +53 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/inline-threads.d.ts +160 -0
- package/dist/core/inline-threads.d.ts.map +1 -0
- package/dist/core/inline-threads.js +369 -0
- package/dist/core/inline-threads.js.map +1 -0
- package/dist/core/multi-pass-aggregate.d.ts +161 -0
- package/dist/core/multi-pass-aggregate.d.ts.map +1 -0
- package/dist/core/multi-pass-aggregate.js +457 -0
- package/dist/core/multi-pass-aggregate.js.map +1 -0
- package/dist/core/plan-review.d.ts +57 -0
- package/dist/core/plan-review.d.ts.map +1 -0
- package/dist/core/plan-review.js +77 -0
- package/dist/core/plan-review.js.map +1 -0
- package/dist/core/prompt-builder.d.ts +18 -0
- package/dist/core/prompt-builder.d.ts.map +1 -1
- package/dist/core/prompt-builder.js +24 -2
- package/dist/core/prompt-builder.js.map +1 -1
- package/dist/core/render.d.ts.map +1 -1
- package/dist/core/render.js +1 -19
- package/dist/core/render.js.map +1 -1
- package/dist/core/resolve-verifier.d.ts +53 -0
- package/dist/core/resolve-verifier.d.ts.map +1 -0
- package/dist/core/resolve-verifier.js +204 -0
- package/dist/core/resolve-verifier.js.map +1 -0
- package/dist/core/review-context.d.ts +39 -0
- package/dist/core/review-context.d.ts.map +1 -0
- package/dist/core/review-context.js +99 -0
- package/dist/core/review-context.js.map +1 -0
- package/dist/core/review-plan.d.ts +219 -0
- package/dist/core/review-plan.d.ts.map +1 -0
- package/dist/core/review-plan.js +274 -0
- package/dist/core/review-plan.js.map +1 -0
- package/dist/core/review-writeback.d.ts +74 -0
- package/dist/core/review-writeback.d.ts.map +1 -1
- package/dist/core/review-writeback.js +71 -4
- package/dist/core/review-writeback.js.map +1 -1
- package/dist/core/skills.d.ts +25 -0
- package/dist/core/skills.d.ts.map +1 -1
- package/dist/core/skills.js +100 -4
- package/dist/core/skills.js.map +1 -1
- package/dist/core/version.d.ts +2 -0
- package/dist/core/version.d.ts.map +1 -0
- package/dist/core/version.js +4 -0
- package/dist/core/version.js.map +1 -0
- package/package.json +11 -5
- package/src/cli/configure-github.ts +329 -0
- package/src/cli/hooks.ts +155 -0
- package/src/cli/index.ts +6 -0
- package/src/cli/main.ts +1031 -37
- package/src/cli/post-check-run.ts +115 -0
- package/src/cli/review-prompt.ts +355 -0
- package/src/cli/skills.ts +42 -2
- package/src/cli/update-notifier.ts +100 -0
- package/src/cli/update.ts +60 -11
- package/src/core/auto-resolve.ts +488 -0
- package/src/core/budget-plan.ts +324 -0
- package/src/core/check-verdict.ts +93 -0
- package/src/core/configure-github.ts +497 -0
- package/src/core/design.ts +73 -0
- package/src/core/diff-findings.ts +323 -0
- package/src/core/formal-review.ts +168 -0
- package/src/core/index.ts +188 -0
- package/src/core/inline-threads.ts +471 -0
- package/src/core/multi-pass-aggregate.ts +591 -0
- package/src/core/plan-review.ts +136 -0
- package/src/core/prompt-builder.ts +44 -2
- package/src/core/render.ts +10 -16
- package/src/core/resolve-verifier.ts +254 -0
- package/src/core/review-context.ts +106 -0
- package/src/core/review-plan.ts +485 -0
- package/src/core/review-writeback.ts +161 -4
- package/src/core/skills.ts +128 -5
- package/src/core/version.ts +3 -0
- package/templates/clud-bug-review.md.tmpl +115 -0
- package/templates/skills/design/design-system-consistency.md +41 -0
- package/templates/skills/design/frontend-a11y.md +41 -0
- package/templates/skills/design/visual-polish.md +41 -0
- package/templates/workflow-py.yml.tmpl +92 -2
- package/templates/workflow-ts.yml.tmpl +92 -2
- package/templates/workflow.yml.tmpl +173 -2
package/dist/cli/main.js
CHANGED
|
@@ -22,10 +22,12 @@ import { detect, buildDescriptionLine } from '../core/detect.js';
|
|
|
22
22
|
import { renderFile, pickTemplate, templateLanguage } from '../core/render.js';
|
|
23
23
|
import { reviewPrompt } from '../core/prompts.js';
|
|
24
24
|
import { SkillsClient, rankAndCap } from '../core/skills.js';
|
|
25
|
-
import { writeSkills, writeSkill, loadBaseline, readManifest, writeManifest, removeSkill, listInstalled, diffManifest, } from './skills.js';
|
|
25
|
+
import { writeSkills, writeSkill, loadBaseline, loadDesignKit, readManifest, writeManifest, removeSkill, listInstalled, diffManifest, } from './skills.js';
|
|
26
26
|
import { computeAuditFileSet } from './audit.js';
|
|
27
27
|
import { renderAuditHeader } from '../core/audit.js';
|
|
28
28
|
import { runUpdate } from './update.js';
|
|
29
|
+
import { runReviewPrompt } from './review-prompt.js';
|
|
30
|
+
import { runPostCheckRun } from './post-check-run.js';
|
|
29
31
|
import { getPendingWorkflowEdits, makeBranchName, git as gitCmd } from './edit-workflow.js';
|
|
30
32
|
import { applyToRepo as applyAgentDocs } from './agents-md.js';
|
|
31
33
|
import { detectRepo, detectDefaultBranch, getProtectionState, enableConversationResolution } from './branch-protection.js';
|
|
@@ -37,6 +39,7 @@ import { computeReviewCost, costPerLOC, cacheHitRate, extractTokensFromLog, roll
|
|
|
37
39
|
const PKG_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
38
40
|
const TEMPLATES = join(PKG_ROOT, 'templates');
|
|
39
41
|
const BASELINE_DIR = join(TEMPLATES, 'skills', 'baseline');
|
|
42
|
+
const DESIGN_DIR = join(TEMPLATES, 'skills', 'design');
|
|
40
43
|
function parseArgs(argv) {
|
|
41
44
|
const args = {
|
|
42
45
|
_: [], offline: false, acceptAll: false, commit: false, help: false, version: false,
|
|
@@ -55,6 +58,25 @@ function parseArgs(argv) {
|
|
|
55
58
|
// users get the same one-command bootstrap as Python-first users
|
|
56
59
|
// (logmind v0.6.8's --with-skdd is the symmetric counterpart).
|
|
57
60
|
withSkdd: false,
|
|
61
|
+
// v0.7.0 (Wave 6b): `clud-bug init --with-local-review` also scaffolds
|
|
62
|
+
// .claude/commands/clud-bug-review.md so `/clud-bug-review` works in a
|
|
63
|
+
// Claude Code session (reviews the current PR with the session's tokens).
|
|
64
|
+
withLocalReview: false,
|
|
65
|
+
withHooks: false,
|
|
66
|
+
// rc.16: `clud-bug init --with-design` installs the design-critic kit
|
|
67
|
+
// (3 `kind: design` skills) and flips the off-by-default `design` block to
|
|
68
|
+
// enabled so the visual review lens runs (local recipe + hosted bot).
|
|
69
|
+
withDesign: false,
|
|
70
|
+
// rc.18: `clud-bug init --local-only` installs MAX MODE (skills + slash
|
|
71
|
+
// command + commit hook), but SKIPS the GitHub Action workflows (which run
|
|
72
|
+
// claude-code-action with ANTHROPIC_API_KEY). For repos that review on a
|
|
73
|
+
// Claude subscription via the in-session hook, not a billed CI Action.
|
|
74
|
+
// Implies --with-local-review + --with-hooks.
|
|
75
|
+
localOnly: false,
|
|
76
|
+
// v0.7.0-rc.4: `clud-bug configure-github` flags.
|
|
77
|
+
// --dry-run prints the diff but skips PATCH; --branch overrides "main".
|
|
78
|
+
dryRun: false,
|
|
79
|
+
branch: null,
|
|
58
80
|
};
|
|
59
81
|
for (let i = 0; i < argv.length; i++) {
|
|
60
82
|
const a = argv[i];
|
|
@@ -96,6 +118,39 @@ function parseArgs(argv) {
|
|
|
96
118
|
args.artifacts = false;
|
|
97
119
|
else if (a === '--with-skdd')
|
|
98
120
|
args.withSkdd = true;
|
|
121
|
+
else if (a === '--with-local-review')
|
|
122
|
+
args.withLocalReview = true;
|
|
123
|
+
else if (a === '--with-hooks')
|
|
124
|
+
args.withHooks = true;
|
|
125
|
+
else if (a === '--with-design')
|
|
126
|
+
args.withDesign = true;
|
|
127
|
+
else if (a === '--local-only')
|
|
128
|
+
args.localOnly = true;
|
|
129
|
+
else if (a === '--dry-run')
|
|
130
|
+
args.dryRun = true;
|
|
131
|
+
else if (a === '--branch')
|
|
132
|
+
args.branch = argv[++i];
|
|
133
|
+
else if (a === '--trigger')
|
|
134
|
+
args.trigger = argv[++i];
|
|
135
|
+
else if (a === '--diff-size')
|
|
136
|
+
args.diffSizeBytes = Number(argv[++i]);
|
|
137
|
+
// H3: `clud-bug post-check-run` flags.
|
|
138
|
+
else if (a === '--sha')
|
|
139
|
+
args.sha = argv[++i];
|
|
140
|
+
else if (a === '--verdict')
|
|
141
|
+
args.verdict = argv[++i];
|
|
142
|
+
else if (a === '--critical-count')
|
|
143
|
+
args.criticalCount = Number(argv[++i]);
|
|
144
|
+
else if (a === '--source')
|
|
145
|
+
args.source = argv[++i];
|
|
146
|
+
else if (a === '--strict')
|
|
147
|
+
args.strict = true;
|
|
148
|
+
else if (a === '--no-strict')
|
|
149
|
+
args.strict = false;
|
|
150
|
+
else if (a === '--owner')
|
|
151
|
+
args.owner = argv[++i];
|
|
152
|
+
else if (a === '--details-url')
|
|
153
|
+
args.detailsUrl = argv[++i];
|
|
99
154
|
else
|
|
100
155
|
args._.push(a);
|
|
101
156
|
}
|
|
@@ -117,6 +172,12 @@ Commands:
|
|
|
117
172
|
Use --since / --changed-in / --scope to narrow.
|
|
118
173
|
update Re-render workflows + refresh baseline specimens to the latest shipped
|
|
119
174
|
templates. Custom and skills.sh-installed specimens left alone.
|
|
175
|
+
configure-github <owner>/<repo>
|
|
176
|
+
Apply the SPEC §7 canonical branch protection ruleset to a repo.
|
|
177
|
+
Auth: GITHUB_TOKEN env first, then \`gh auth token\`. Use
|
|
178
|
+
--dry-run to print the diff without PATCH-ing; --branch to
|
|
179
|
+
target a non-main branch. Idempotent — already-canonical
|
|
180
|
+
repos exit 0 with no changes.
|
|
120
181
|
edit-workflow Helper for editing .github/workflows/clud-bug-*.yml in an isolated
|
|
121
182
|
PR (the action refuses to review PRs that modify its own workflow).
|
|
122
183
|
usage Read recent clud-bug-review run JSON + normalize cost per LOC.
|
|
@@ -150,11 +211,67 @@ Commands:
|
|
|
150
211
|
GitHub-markdown summary comment shape. Invoked by the
|
|
151
212
|
workflow post-step; output is what \`gh pr comment\`
|
|
152
213
|
receives. Empty stdin or non-object payload exits 2.
|
|
214
|
+
select-review-event Compute the SPEC §7.2.1 formal-review event (APPROVE /
|
|
215
|
+
--stdin REQUEST_CHANGES / COMMENT / skip) from a structured-output
|
|
216
|
+
JSON payload + a few env-passed PR-author fields. Used by
|
|
217
|
+
the v0.7.0-rc.3 workflow post-step to post a formal
|
|
218
|
+
\`pulls.createReview\` call so the canonical ruleset's
|
|
219
|
+
\`required_approving_review_count: 1\` floor is auto-
|
|
220
|
+
satisfied by clud-bug[bot] on clean reviews.
|
|
221
|
+
Required env vars: PR_AUTHOR_LOGIN, PR_AUTHOR_ASSOCIATION,
|
|
222
|
+
STRICT_MODE ("true"/"false"). Empty/malformed stdin →
|
|
223
|
+
exits 0 with "skip" on stdout (no-op; workflow degrades
|
|
224
|
+
gracefully to the existing comment-only behavior).
|
|
225
|
+
post-inline-threads Post per-finding inline review threads (D.2.X). Reads a
|
|
226
|
+
--stdin structured-output JSON payload from stdin, fetches the
|
|
227
|
+
PR diff via \`gh api\`, posts one batched review with one
|
|
228
|
+
comment per anchorable finding. Each comment body carries
|
|
229
|
+
a hidden \`<!-- finding-id: <hash> --> \`marker so Wave 5b
|
|
230
|
+
auto-resolve can re-match findings on subsequent pushes
|
|
231
|
+
without persistent state. Required env vars: GH_TOKEN,
|
|
232
|
+
REPO ("owner/name"), PR_NUMBER, HEAD_SHA. Output is a
|
|
233
|
+
JSON status report \`{posted, skipped, preexisting, error?}\`.
|
|
234
|
+
resolve-threads Auto-resolve inline review threads when a fix-push
|
|
235
|
+
addresses the original finding (D.2.6). Fetches all
|
|
236
|
+
bot-authored unresolved threads, asks the Anthropic
|
|
237
|
+
Messages API per-thread whether the new commit addressed
|
|
238
|
+
the concern, then resolves (with a marker reply)
|
|
239
|
+
ADDRESSED threads + leaves UNCERTAIN/NOT_ADDRESSED open.
|
|
240
|
+
Fail-closed: verifier errors route to UNCERTAIN.
|
|
241
|
+
Required env vars: GH_TOKEN, ANTHROPIC_API_KEY,
|
|
242
|
+
REPO ("owner/name"), PR_NUMBER. Output: JSON
|
|
243
|
+
\`{actions, verifierCallCount, shouldRequestChanges}\`.
|
|
244
|
+
review-prompt Emit the local-review recipe — the structured prompt a
|
|
245
|
+
Claude Code agent/hook runs to review the current diff on
|
|
246
|
+
the session's own subscription. Plans via the shared
|
|
247
|
+
engine: --trigger commit (default) → a fast single pass;
|
|
248
|
+
push/pr → the full multi-pass plan. Prints to stdout.
|
|
249
|
+
post-check-run Post the \`clud-bug-review\` GitHub check so branch
|
|
250
|
+
protection can gate the merge (H3). --verdict
|
|
251
|
+
clean|critical|failed --sha <sha> [--critical-count N]
|
|
252
|
+
[--source local|ci] [--strict|--no-strict] [--dry-run].
|
|
253
|
+
clean→success, critical+strict→failure, else neutral.
|
|
153
254
|
|
|
154
255
|
Options:
|
|
155
256
|
--offline Skip skills.sh; pin only the bundled baseline specimens.
|
|
156
257
|
--accept-all,-y Accept the recommended specimens without prompting.
|
|
157
258
|
--commit git add + commit the generated kit when done (init only).
|
|
259
|
+
--with-local-review (init) Scaffold .claude/commands/clud-bug-review.md so
|
|
260
|
+
\`/clud-bug-review\` works in a Claude Code session —
|
|
261
|
+
reviews the current branch's PR using that session's
|
|
262
|
+
own tokens (no hosted App, no extra auth).
|
|
263
|
+
--with-hooks (init) Scaffold a native Claude Code \`type: command\`
|
|
264
|
+
commit-review hook into .claude/settings.json — on every
|
|
265
|
+
\`git commit\` / \`logmind log\`, it fetches a review recipe and
|
|
266
|
+
surfaces it to the agent (on this session's subscription)
|
|
267
|
+
via asyncRewake. Implies --with-local-review. Off by default.
|
|
268
|
+
--with-design (init) Install the design-critic kit (3 \`kind: design\`
|
|
269
|
+
skills) and enable the off-by-default visual review
|
|
270
|
+
lens — renders changed UI and critiques it. Off by default.
|
|
271
|
+
--local-only (init) MAX MODE: install the slash command + commit hook
|
|
272
|
+
(implies --with-local-review + --with-hooks) but SKIP the
|
|
273
|
+
GitHub Action workflows. Reviews run in your Claude Code
|
|
274
|
+
session on your subscription — no API key, no per-review bill.
|
|
158
275
|
--quiet,-q Token-frugal mode for agent invocations. Suppresses
|
|
159
276
|
progress chatter; emits exactly one final
|
|
160
277
|
\`ok <key-value>\` summary line per command. Errors
|
|
@@ -164,6 +281,10 @@ Options:
|
|
|
164
281
|
required_conversation_resolution on the default
|
|
165
282
|
branch (init only). Use for repos that manage
|
|
166
283
|
branch protection via ruleset or org policy.
|
|
284
|
+
--dry-run Print the canonical-v1 diff without PATCH-ing
|
|
285
|
+
(configure-github only).
|
|
286
|
+
--branch <name> Target branch for configure-github (default: main).
|
|
287
|
+
--trigger <ctx> review-prompt context: commit (default) | push | pr.
|
|
167
288
|
--repo <owner/name> Restrict \`usage\` to a single repo. Default: all repos
|
|
168
289
|
with clud-bug-review.yml in the gh user's auth scope.
|
|
169
290
|
--pr <N> Restrict \`usage\` to a single PR.
|
|
@@ -194,6 +315,15 @@ async function main() {
|
|
|
194
315
|
if (args.quiet)
|
|
195
316
|
setQuiet(true);
|
|
196
317
|
const cmd = args._[0];
|
|
318
|
+
// rc.20 — update notifier (brew/gh/vercel pattern). Only on an interactive
|
|
319
|
+
// terminal, and never for the machine-consumed verbs (the hook runs
|
|
320
|
+
// review-prompt in a non-TTY subprocess, so this is doubly skipped there).
|
|
321
|
+
// Best-effort + cache-backed, so it adds no latency to the command.
|
|
322
|
+
const MACHINE_VERBS = new Set(['review-prompt', 'post-check-run', 'render', 'update-skill-usage']);
|
|
323
|
+
if (process.stderr.isTTY && !MACHINE_VERBS.has(cmd)) {
|
|
324
|
+
const { maybeNotifyUpdate } = await import('./update-notifier.js');
|
|
325
|
+
await maybeNotifyUpdate(await readPkgVersion());
|
|
326
|
+
}
|
|
197
327
|
switch (cmd) {
|
|
198
328
|
case 'init': return runInit(args);
|
|
199
329
|
case 'list': return runList(args);
|
|
@@ -203,10 +333,16 @@ async function main() {
|
|
|
203
333
|
case 'audit': return runAudit(args);
|
|
204
334
|
case 'update': return runUpdateCmd(args);
|
|
205
335
|
case 'edit-workflow': return runEditWorkflow(args);
|
|
336
|
+
case 'configure-github': return runConfigureGithubCmd(args);
|
|
206
337
|
case 'usage': return runUsage(args);
|
|
207
338
|
case 'eval': return runEval();
|
|
208
339
|
case 'render': return runRender(args);
|
|
209
340
|
case 'update-skill-usage': return runUpdateSkillUsage(args);
|
|
341
|
+
case 'select-review-event': return runSelectReviewEvent(args);
|
|
342
|
+
case 'post-inline-threads': return runPostInlineThreads(args);
|
|
343
|
+
case 'resolve-threads': return runResolveThreads(args);
|
|
344
|
+
case 'review-prompt': return runReviewPrompt(args);
|
|
345
|
+
case 'post-check-run': return runPostCheckRun(args);
|
|
210
346
|
default:
|
|
211
347
|
process.stderr.write(`Unknown command: ${cmd || '(none)'}\n\n${HELP}`);
|
|
212
348
|
process.exit(2);
|
|
@@ -340,6 +476,645 @@ async function runUpdateSkillUsage(args) {
|
|
|
340
476
|
const skillCount = Object.keys(delta).length;
|
|
341
477
|
ok(`update-skill-usage: merged ${skillCount} skill${skillCount === 1 ? '' : 's'} from review`);
|
|
342
478
|
}
|
|
479
|
+
// v0.7.0-rc.3 — SPEC §7.2.1 formal-review event selector for the npm
|
|
480
|
+
// workflow path. Reads the action's structured_output JSON from stdin,
|
|
481
|
+
// PR-author metadata from env, and emits ONE word on stdout:
|
|
482
|
+
//
|
|
483
|
+
// APPROVE | REQUEST_CHANGES | COMMENT | skip
|
|
484
|
+
//
|
|
485
|
+
// The workflow template post-step pipes the structured_output payload
|
|
486
|
+
// in, reads the verdict, then conditionally calls `gh api ... /reviews`
|
|
487
|
+
// with `event=$VERDICT` (skipping the API call when verdict is "skip").
|
|
488
|
+
//
|
|
489
|
+
// Robustness contract: this command MUST NEVER fail the workflow on
|
|
490
|
+
// caller-side problems (missing/malformed payload, missing env). Any
|
|
491
|
+
// such case prints "skip" to stdout + a warning to stderr and exits 0
|
|
492
|
+
// — the workflow then degrades to the existing comment-only behavior.
|
|
493
|
+
// We only exit non-zero on programmer error (unknown command flags),
|
|
494
|
+
// which the workflow templates don't pass.
|
|
495
|
+
//
|
|
496
|
+
// Required env vars (the workflow templates set all four):
|
|
497
|
+
// PR_AUTHOR_LOGIN — github.event.pull_request.user.login
|
|
498
|
+
// PR_AUTHOR_ASSOCIATION — github.event.pull_request.author_association
|
|
499
|
+
// STRICT_MODE — "true" / "false" from the base-ref manifest
|
|
500
|
+
// (PR_AUTHOR_ASSOCIATION missing → "CONTRIBUTOR" default which preserves
|
|
501
|
+
// pre-§7.2.1 trusted-author semantics; PR_AUTHOR_LOGIN missing → "skip".)
|
|
502
|
+
async function runSelectReviewEvent(args) {
|
|
503
|
+
const { selectReviewEvent } = await import('../core/formal-review.js');
|
|
504
|
+
if (!args.stdin) {
|
|
505
|
+
process.stderr.write('clud-bug select-review-event: --stdin is required.\n');
|
|
506
|
+
process.stdout.write('skip\n');
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
let raw = '';
|
|
510
|
+
for await (const chunk of process.stdin)
|
|
511
|
+
raw += chunk;
|
|
512
|
+
raw = raw.trim();
|
|
513
|
+
if (!raw) {
|
|
514
|
+
process.stderr.write('clud-bug select-review-event: stdin empty — emitting "skip".\n');
|
|
515
|
+
process.stdout.write('skip\n');
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
let payload;
|
|
519
|
+
try {
|
|
520
|
+
payload = JSON.parse(raw);
|
|
521
|
+
}
|
|
522
|
+
catch (e) {
|
|
523
|
+
process.stderr.write(`clud-bug select-review-event: JSON parse failed: ${e.message} — emitting "skip".\n`);
|
|
524
|
+
process.stdout.write('skip\n');
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
if (!payload || typeof payload !== 'object') {
|
|
528
|
+
process.stderr.write('clud-bug select-review-event: payload must be a JSON object — emitting "skip".\n');
|
|
529
|
+
process.stdout.write('skip\n');
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
// Count findings from the structured_output array shape. Each is
|
|
533
|
+
// optional — the model may emit absent / null / wrong-type arrays
|
|
534
|
+
// when its output drifts from schema; we tolerate all of those.
|
|
535
|
+
const criticalCount = Array.isArray(payload.critical_findings)
|
|
536
|
+
? payload.critical_findings.length
|
|
537
|
+
: 0;
|
|
538
|
+
const minorCount = Array.isArray(payload.minor_findings)
|
|
539
|
+
? payload.minor_findings.length
|
|
540
|
+
: 0;
|
|
541
|
+
// Env-passed metadata. Empty PR_AUTHOR_LOGIN means we have no idea
|
|
542
|
+
// who opened the PR — safer to skip than to attempt a formal review
|
|
543
|
+
// that might collide with the self-PR guard mis-routed.
|
|
544
|
+
const prAuthorLogin = String(process.env.PR_AUTHOR_LOGIN ?? '').trim();
|
|
545
|
+
if (prAuthorLogin === '') {
|
|
546
|
+
process.stderr.write('clud-bug select-review-event: PR_AUTHOR_LOGIN unset — emitting "skip".\n');
|
|
547
|
+
process.stdout.write('skip\n');
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
// author_association resolution — security-critical fallback ladder.
|
|
551
|
+
//
|
|
552
|
+
// SPEC §7.2.1 + clud-bug-review #163 feedback: the §7.2.1 gate exists
|
|
553
|
+
// precisely so a future REST-API rename of an external tier doesn't
|
|
554
|
+
// silently degrade to "trusted → APPROVE". The fallback below splits
|
|
555
|
+
// the two error cases by what they MEAN:
|
|
556
|
+
//
|
|
557
|
+
// 1. EMPTY ("") — caller didn't pass author_association at all.
|
|
558
|
+
// This is the "older webhook payload / non-GH caller" case.
|
|
559
|
+
// Pre-§7.2.1, those callers got APPROVE on clean reviews; we
|
|
560
|
+
// preserve that by defaulting to 'CONTRIBUTOR' (org-trusted).
|
|
561
|
+
// Documented + tested under "missing PR_AUTHOR_ASSOCIATION".
|
|
562
|
+
//
|
|
563
|
+
// 2. NON-EMPTY UNRECOGNIZED ("FUTURE_TIER") — caller passed a
|
|
564
|
+
// token this build doesn't know about. This is the rename
|
|
565
|
+
// scenario the §7.2.1 gate is explicitly defending against.
|
|
566
|
+
// Fail CLOSED: treat as external → 'NONE' → COMMENT, NEVER
|
|
567
|
+
// APPROVE. If the rename was for a still-trusted tier (e.g.
|
|
568
|
+
// 'STAFF'), the cost is one extra COMMENT review pending
|
|
569
|
+
// human approval; if it was a new external tier, we've closed
|
|
570
|
+
// the drive-by exploit. Asymmetric risk → fail closed.
|
|
571
|
+
const rawAssoc = String(process.env.PR_AUTHOR_ASSOCIATION ?? '').trim();
|
|
572
|
+
const validAssociations = new Set([
|
|
573
|
+
'OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR',
|
|
574
|
+
'FIRST_TIME_CONTRIBUTOR', 'FIRST_TIMER', 'NONE', 'MANNEQUIN',
|
|
575
|
+
]);
|
|
576
|
+
let authorAssociation;
|
|
577
|
+
if (rawAssoc === '') {
|
|
578
|
+
// Back-compat for legacy callers without author_association.
|
|
579
|
+
authorAssociation = 'CONTRIBUTOR';
|
|
580
|
+
}
|
|
581
|
+
else if (validAssociations.has(rawAssoc)) {
|
|
582
|
+
authorAssociation = rawAssoc;
|
|
583
|
+
}
|
|
584
|
+
else {
|
|
585
|
+
// Fail-closed for unrecognized future tokens. 'NONE' is the
|
|
586
|
+
// weakest external tier and the safest default — selectReviewEvent
|
|
587
|
+
// routes it to COMMENT (never APPROVE, never REQUEST_CHANGES).
|
|
588
|
+
process.stderr.write(`clud-bug select-review-event: unrecognized PR_AUTHOR_ASSOCIATION="${rawAssoc}" — treating as external (fail-closed per SPEC §7.2.1).\n`);
|
|
589
|
+
authorAssociation = 'NONE';
|
|
590
|
+
}
|
|
591
|
+
const strictMode = String(process.env.STRICT_MODE ?? '').trim() === 'true';
|
|
592
|
+
const event = selectReviewEvent({
|
|
593
|
+
criticalCount,
|
|
594
|
+
minorCount,
|
|
595
|
+
strictMode,
|
|
596
|
+
prAuthorLogin,
|
|
597
|
+
authorAssociation,
|
|
598
|
+
});
|
|
599
|
+
process.stdout.write(event + '\n');
|
|
600
|
+
}
|
|
601
|
+
// Wave 5a / 0.7.0-rc.7: post per-finding inline review threads (D.2.X)
|
|
602
|
+
// to the PR. Replaces the legacy "one summary comment listing every
|
|
603
|
+
// finding" UX with first-class GitHub review threads users can reply to
|
|
604
|
+
// and (in Wave 5b) the bot can auto-resolve on fix-push.
|
|
605
|
+
//
|
|
606
|
+
// Pipeline:
|
|
607
|
+
// 1. Read the structured review JSON from stdin (same shape the
|
|
608
|
+
// `render` and `update-skill-usage` verbs consume).
|
|
609
|
+
// 2. Fetch the PR's per-file diff via `gh api repos/.../pulls/N/files`.
|
|
610
|
+
// 3. Call `planInlineThreads(findings, diffFiles)` from
|
|
611
|
+
// `clud-bug/core/inline-threads` to partition into anchored
|
|
612
|
+
// `comments[]` + skipped + preexisting buckets.
|
|
613
|
+
// 4. If any comments survive: POST one batched review via
|
|
614
|
+
// `gh api -X POST repos/.../pulls/N/reviews` with `event: COMMENT`.
|
|
615
|
+
// Each comment body carries a hidden `<!-- finding-id: <hash> -->`
|
|
616
|
+
// marker so Wave 5b auto-resolve can re-derive the same ids on
|
|
617
|
+
// subsequent pushes without a persistent store.
|
|
618
|
+
// 5. Emit a JSON summary on stdout (`{posted, skipped, preexisting}`).
|
|
619
|
+
//
|
|
620
|
+
// Required env vars (the workflow template provides all of them):
|
|
621
|
+
// GH_TOKEN, REPO ("owner/name"), PR_NUMBER, HEAD_SHA
|
|
622
|
+
//
|
|
623
|
+
// Failure posture: any `gh api` failure is logged to stderr + emitted in
|
|
624
|
+
// the stdout JSON as `error`, but the verb exits 0 so the surrounding
|
|
625
|
+
// workflow step's `continue-on-error: true` is the single source of
|
|
626
|
+
// failure semantics. Mirrors the `render` / `update-skill-usage` posture.
|
|
627
|
+
async function runPostInlineThreads(args) {
|
|
628
|
+
const { planInlineThreads } = await import('../core/inline-threads.js');
|
|
629
|
+
if (!args.stdin) {
|
|
630
|
+
process.stderr.write('clud-bug post-inline-threads: --stdin is required.\n');
|
|
631
|
+
process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'no-stdin' }) + '\n');
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
let raw = '';
|
|
635
|
+
for await (const chunk of process.stdin)
|
|
636
|
+
raw += chunk;
|
|
637
|
+
raw = raw.trim();
|
|
638
|
+
if (!raw) {
|
|
639
|
+
process.stderr.write('clud-bug post-inline-threads: stdin empty — no-op.\n');
|
|
640
|
+
process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'empty-stdin' }) + '\n');
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
let payload;
|
|
644
|
+
try {
|
|
645
|
+
payload = JSON.parse(raw);
|
|
646
|
+
}
|
|
647
|
+
catch (e) {
|
|
648
|
+
process.stderr.write(`clud-bug post-inline-threads: JSON parse failed: ${e.message} — no-op.\n`);
|
|
649
|
+
process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'parse-failed' }) + '\n');
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
if (!payload || typeof payload !== 'object') {
|
|
653
|
+
process.stderr.write('clud-bug post-inline-threads: payload must be a JSON object — no-op.\n');
|
|
654
|
+
process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'not-object' }) + '\n');
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
// Pull findings from the structured_output shape — same field names as
|
|
658
|
+
// the SPEC §1.8.1 review schema the model emits.
|
|
659
|
+
const findings = [];
|
|
660
|
+
for (const f of Array.isArray(payload.critical_findings) ? payload.critical_findings : []) {
|
|
661
|
+
if (f && typeof f === 'object') {
|
|
662
|
+
findings.push({ ...f, severity: 'critical' });
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
for (const f of Array.isArray(payload.minor_findings) ? payload.minor_findings : []) {
|
|
666
|
+
if (f && typeof f === 'object') {
|
|
667
|
+
findings.push({ ...f, severity: 'minor' });
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
// `preexisting_findings` are intentionally not threaded (informational
|
|
671
|
+
// about prior code; not a reason to block this PR). `planInlineThreads`
|
|
672
|
+
// partitions them into the `preexisting` bucket if any are included, but
|
|
673
|
+
// the workflow path doesn't ship them — keep the payload focused.
|
|
674
|
+
if (findings.length === 0) {
|
|
675
|
+
process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], reason: 'no-findings' }) + '\n');
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
// Required env vars.
|
|
679
|
+
const repo = String(process.env.REPO ?? '').trim();
|
|
680
|
+
const prNumberRaw = String(process.env.PR_NUMBER ?? '').trim();
|
|
681
|
+
const headSha = String(process.env.HEAD_SHA ?? '').trim();
|
|
682
|
+
const prNumber = Number(prNumberRaw);
|
|
683
|
+
if (!repo || !repo.includes('/') || !Number.isInteger(prNumber) || prNumber <= 0 || !headSha) {
|
|
684
|
+
process.stderr.write(`clud-bug post-inline-threads: REPO + PR_NUMBER + HEAD_SHA env vars required (got REPO=${repo}, PR_NUMBER=${prNumberRaw}, HEAD_SHA=${headSha ? '<set>' : '<unset>'}).\n`);
|
|
685
|
+
process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'missing-env' }) + '\n');
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
// Fetch the PR's diff. `gh api repos/.../pulls/N/files --paginate`
|
|
689
|
+
// returns a JSON array of {filename, patch, status, ...}. The single
|
|
690
|
+
// -slurp on multi-page output stitches all pages into one big array.
|
|
691
|
+
const filesResult = spawnSync('gh', [
|
|
692
|
+
'api',
|
|
693
|
+
`repos/${repo}/pulls/${prNumber}/files`,
|
|
694
|
+
'--paginate',
|
|
695
|
+
'--slurp',
|
|
696
|
+
], { encoding: 'utf8' });
|
|
697
|
+
if (filesResult.status !== 0) {
|
|
698
|
+
process.stderr.write(`clud-bug post-inline-threads: \`gh api .../files\` failed (exit ${filesResult.status}): ${(filesResult.stderr || '').slice(0, 500)}\n`);
|
|
699
|
+
process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'diff-fetch-failed' }) + '\n');
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
let diffFiles = [];
|
|
703
|
+
try {
|
|
704
|
+
// --slurp wraps each page in an outer array; flatten one level.
|
|
705
|
+
const pages = JSON.parse(filesResult.stdout);
|
|
706
|
+
if (Array.isArray(pages)) {
|
|
707
|
+
for (const page of pages) {
|
|
708
|
+
if (Array.isArray(page))
|
|
709
|
+
diffFiles.push(...page);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
catch (e) {
|
|
714
|
+
process.stderr.write(`clud-bug post-inline-threads: diff JSON parse failed: ${e.message}\n`);
|
|
715
|
+
process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'diff-parse-failed' }) + '\n');
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const plan = planInlineThreads(findings, diffFiles);
|
|
719
|
+
if (plan.comments.length === 0) {
|
|
720
|
+
// Everything fell through to summary-comment fallback (no anchor
|
|
721
|
+
// matches). Not a failure — emit the breakdown so the workflow log
|
|
722
|
+
// explains why no inline threads were posted.
|
|
723
|
+
process.stdout.write(JSON.stringify({
|
|
724
|
+
posted: 0,
|
|
725
|
+
skipped: plan.skipped.map((f) => ({ skill: f.skill, file: f.file, line: f.line, reason: 'not-anchorable' })),
|
|
726
|
+
preexisting: plan.preexisting.map((f) => ({ skill: f.skill })),
|
|
727
|
+
reason: 'no-anchorable-findings',
|
|
728
|
+
}) + '\n');
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
// Post the review. gh accepts the body as a JSON file via --input — we
|
|
732
|
+
// pipe through stdin with `--input -`. Build the body as JSON-on-one-line
|
|
733
|
+
// so stdin is a single write that `gh` reads start-to-finish.
|
|
734
|
+
const reviewBody = JSON.stringify({
|
|
735
|
+
commit_id: headSha,
|
|
736
|
+
event: 'COMMENT',
|
|
737
|
+
body: `Clud-Bug posted ${plan.comments.length} inline finding${plan.comments.length === 1 ? '' : 's'}.`,
|
|
738
|
+
comments: plan.comments,
|
|
739
|
+
});
|
|
740
|
+
const postResult = spawnSync('gh', [
|
|
741
|
+
'api',
|
|
742
|
+
'--method', 'POST',
|
|
743
|
+
`repos/${repo}/pulls/${prNumber}/reviews`,
|
|
744
|
+
'--input', '-',
|
|
745
|
+
], { encoding: 'utf8', input: reviewBody });
|
|
746
|
+
if (postResult.status !== 0) {
|
|
747
|
+
process.stderr.write(`clud-bug post-inline-threads: \`gh api -X POST .../reviews\` failed (exit ${postResult.status}): ${(postResult.stderr || '').slice(0, 500)}\n`);
|
|
748
|
+
process.stdout.write(JSON.stringify({
|
|
749
|
+
posted: 0,
|
|
750
|
+
skipped: plan.skipped.map((f) => ({ skill: f.skill, file: f.file, line: f.line, reason: 'not-anchorable' })),
|
|
751
|
+
preexisting: plan.preexisting.map((f) => ({ skill: f.skill })),
|
|
752
|
+
error: 'review-post-failed',
|
|
753
|
+
}) + '\n');
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
process.stdout.write(JSON.stringify({
|
|
757
|
+
posted: plan.comments.length,
|
|
758
|
+
skipped: plan.skipped.map((f) => ({ skill: f.skill, file: f.file, line: f.line, reason: 'not-anchorable' })),
|
|
759
|
+
preexisting: plan.preexisting.map((f) => ({ skill: f.skill })),
|
|
760
|
+
}) + '\n');
|
|
761
|
+
}
|
|
762
|
+
// Wave 5b / 0.7.0-rc.8: D.2.6 auto-resolve on fix-push. Fetches all
|
|
763
|
+
// bot-authored unresolved inline threads, asks the Anthropic Messages
|
|
764
|
+
// API per-thread whether the new commit addressed the original
|
|
765
|
+
// finding, and resolves ADDRESSED threads (with a marker reply) while
|
|
766
|
+
// leaving UNCERTAIN/NOT_ADDRESSED open. Fail-closed throughout —
|
|
767
|
+
// verifier errors route to UNCERTAIN; the surrounding workflow
|
|
768
|
+
// step's `continue-on-error: true` is the single failure gate.
|
|
769
|
+
//
|
|
770
|
+
// Required env: GH_TOKEN, ANTHROPIC_API_KEY, REPO ("owner/name"), PR_NUMBER.
|
|
771
|
+
//
|
|
772
|
+
// No `--stdin` — fetches everything from `gh api graphql` itself.
|
|
773
|
+
// Workflow gates this verb on `github.event.action == 'synchronize'`
|
|
774
|
+
// so it only runs on fix-pushes (not initial PR opens, when there
|
|
775
|
+
// are no prior threads to resolve).
|
|
776
|
+
async function runResolveThreads(_args) {
|
|
777
|
+
const { parseThreadBody, extractAnchorContext, REVIEW_THREADS_QUERY, RESOLVE_THREAD_MUTATION, ADD_REPLY_MUTATION, } = await import('../core/inline-threads.js');
|
|
778
|
+
const { readAutoResolveConfigFromCludBug, runAutoResolve, applyResolutionRules, selectResolveAuth, renderResolveMarkerTag, anchorSignature, latestResolveMarker, } = await import('../core/auto-resolve.js');
|
|
779
|
+
const { VERIFIER_SYSTEM, buildVerifierPrompt, parseVerifierResponse, } = await import('../core/resolve-verifier.js');
|
|
780
|
+
// ---- Env validation ----------------------------------------------------
|
|
781
|
+
const repo = String(process.env.REPO ?? '').trim();
|
|
782
|
+
const prNumberRaw = String(process.env.PR_NUMBER ?? '').trim();
|
|
783
|
+
const prNumber = Number(prNumberRaw);
|
|
784
|
+
const ghToken = String(process.env.GH_TOKEN ?? '').trim();
|
|
785
|
+
const anthropicKey = String(process.env.ANTHROPIC_API_KEY ?? '').trim();
|
|
786
|
+
if (!repo || !repo.includes('/') || !Number.isInteger(prNumber) || prNumber <= 0 || !ghToken || !anthropicKey) {
|
|
787
|
+
process.stderr.write(`clud-bug resolve-threads: REPO + PR_NUMBER + GH_TOKEN + ANTHROPIC_API_KEY env vars required.\n`);
|
|
788
|
+
process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, error: 'missing-env' }) + '\n');
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
const [owner, repoName] = repo.split('/', 2);
|
|
792
|
+
// ---- Optional .clud-bug.json config (autoResolve block) ----------------
|
|
793
|
+
// The workflow's working dir is the PR checkout — if .clud-bug.json is
|
|
794
|
+
// present we read autoResolve from it. Otherwise defaults (mode='verified').
|
|
795
|
+
let autoResolveConfig;
|
|
796
|
+
try {
|
|
797
|
+
const cfgPath = join(process.cwd(), '.claude/skills/.clud-bug.json');
|
|
798
|
+
const cfgRaw = await readFile(cfgPath, 'utf8');
|
|
799
|
+
const cfg = JSON.parse(cfgRaw);
|
|
800
|
+
autoResolveConfig = readAutoResolveConfigFromCludBug(cfg, (msg) => {
|
|
801
|
+
process.stderr.write(`clud-bug resolve-threads: config warning: ${msg}\n`);
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
catch (err) {
|
|
805
|
+
// Missing OR unparseable .clud-bug.json — use defaults but log the
|
|
806
|
+
// specific reason so an operator with malformed config can diagnose
|
|
807
|
+
// why their `autoResolve.mode = 'off'` is being silently ignored.
|
|
808
|
+
const code = err && typeof err === 'object' && 'code' in err ? err.code : undefined;
|
|
809
|
+
if (code !== 'ENOENT') {
|
|
810
|
+
process.stderr.write(`clud-bug resolve-threads: .clud-bug.json read/parse error (${err && typeof err === 'object' && 'message' in err ? err.message : String(err)}); using defaults.\n`);
|
|
811
|
+
}
|
|
812
|
+
autoResolveConfig = readAutoResolveConfigFromCludBug(null);
|
|
813
|
+
}
|
|
814
|
+
if (autoResolveConfig.mode === 'off') {
|
|
815
|
+
process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, reason: 'mode-off' }) + '\n');
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
// ---- Fetch threads via GraphQL -----------------------------------------
|
|
819
|
+
// `gh api graphql` uses `-f` for String! variables and `-F` for typed
|
|
820
|
+
// (number / bool) variables. Owner + repo are String! per the query;
|
|
821
|
+
// pr is Int!. Reviewer-flagged: passing owner/repo as `-F` would coerce
|
|
822
|
+
// numeric-looking values incorrectly.
|
|
823
|
+
const threadsResult = spawnSync('gh', [
|
|
824
|
+
'api', 'graphql',
|
|
825
|
+
'-f', `query=${REVIEW_THREADS_QUERY}`,
|
|
826
|
+
'-f', `owner=${owner}`,
|
|
827
|
+
'-f', `repo=${repoName}`,
|
|
828
|
+
'-F', `pr=${prNumber}`,
|
|
829
|
+
], { encoding: 'utf8' });
|
|
830
|
+
if (threadsResult.status !== 0) {
|
|
831
|
+
process.stderr.write(`clud-bug resolve-threads: gh api graphql (threads) failed (exit ${threadsResult.status}): ${(threadsResult.stderr || '').slice(0, 500)}\n`);
|
|
832
|
+
process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, error: 'threads-fetch-failed' }) + '\n');
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
let threadNodes;
|
|
836
|
+
try {
|
|
837
|
+
const parsed = JSON.parse(threadsResult.stdout);
|
|
838
|
+
threadNodes = parsed?.data?.repository?.pullRequest?.reviewThreads?.nodes ?? [];
|
|
839
|
+
if (!Array.isArray(threadNodes))
|
|
840
|
+
threadNodes = [];
|
|
841
|
+
}
|
|
842
|
+
catch (e) {
|
|
843
|
+
process.stderr.write(`clud-bug resolve-threads: threads JSON parse failed: ${e.message}\n`);
|
|
844
|
+
process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, error: 'threads-parse-failed' }) + '\n');
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
// ---- Filter to bot-authored unresolved threads with our marker --------
|
|
848
|
+
// The workflow's GITHUB_TOKEN posts as `github-actions[bot]`. Match both
|
|
849
|
+
// the bracketed bot form and the bare `github-actions` form (GraphQL
|
|
850
|
+
// can return either depending on the node-type field requested).
|
|
851
|
+
const BOT_AUTHORS = new Set(['github-actions', 'github-actions[bot]']);
|
|
852
|
+
const candidates = [];
|
|
853
|
+
for (const t of threadNodes) {
|
|
854
|
+
if (!t || t.isResolved)
|
|
855
|
+
continue;
|
|
856
|
+
const c = t.comments?.nodes?.[0];
|
|
857
|
+
if (!c)
|
|
858
|
+
continue;
|
|
859
|
+
const authorLogin = c.author?.login ?? '';
|
|
860
|
+
if (!BOT_AUTHORS.has(authorLogin))
|
|
861
|
+
continue;
|
|
862
|
+
const parsed = parseThreadBody(c.body ?? '');
|
|
863
|
+
if (!parsed)
|
|
864
|
+
continue;
|
|
865
|
+
if (!c.path || (c.line === null && c.originalLine === null))
|
|
866
|
+
continue;
|
|
867
|
+
candidates.push({
|
|
868
|
+
threadId: t.id,
|
|
869
|
+
file: c.path,
|
|
870
|
+
line: c.line ?? c.originalLine,
|
|
871
|
+
parsed,
|
|
872
|
+
// Wave 5b.1: full comment list so idempotency can spot a prior
|
|
873
|
+
// bot reply's resolve marker (verdict + anchor sig) on this thread.
|
|
874
|
+
comments: t.comments?.nodes ?? [],
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
if (candidates.length === 0) {
|
|
878
|
+
process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, reason: 'no-bot-threads' }) + '\n');
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
// ---- Fetch diff for anchor context -------------------------------------
|
|
882
|
+
const filesResult = spawnSync('gh', [
|
|
883
|
+
'api',
|
|
884
|
+
`repos/${repo}/pulls/${prNumber}/files`,
|
|
885
|
+
'--paginate',
|
|
886
|
+
'--slurp',
|
|
887
|
+
], { encoding: 'utf8' });
|
|
888
|
+
if (filesResult.status !== 0) {
|
|
889
|
+
process.stderr.write(`clud-bug resolve-threads: diff fetch failed (exit ${filesResult.status}): ${(filesResult.stderr || '').slice(0, 500)}\n`);
|
|
890
|
+
process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, error: 'diff-fetch-failed' }) + '\n');
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
let diffFiles = [];
|
|
894
|
+
try {
|
|
895
|
+
const pages = JSON.parse(filesResult.stdout);
|
|
896
|
+
if (Array.isArray(pages)) {
|
|
897
|
+
for (const page of pages) {
|
|
898
|
+
if (Array.isArray(page))
|
|
899
|
+
diffFiles.push(...page);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
catch (e) {
|
|
904
|
+
process.stderr.write(`clud-bug resolve-threads: diff JSON parse failed: ${e.message}\n`);
|
|
905
|
+
process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, error: 'diff-parse-failed' }) + '\n');
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
// ---- Build PriorThread[] for runAutoResolve ----------------------------
|
|
909
|
+
const priorThreads = candidates.map((c) => {
|
|
910
|
+
const ctx = extractAnchorContext({ file: c.file, line: c.line }, diffFiles);
|
|
911
|
+
const findingBody = c.parsed.reasoning
|
|
912
|
+
? `${c.parsed.summary}\n\n${c.parsed.reasoning}`
|
|
913
|
+
: c.parsed.summary;
|
|
914
|
+
return {
|
|
915
|
+
threadId: c.threadId,
|
|
916
|
+
finding: {
|
|
917
|
+
severity: c.parsed.severity,
|
|
918
|
+
body: findingBody,
|
|
919
|
+
skill: c.parsed.skill,
|
|
920
|
+
file: c.file,
|
|
921
|
+
line: c.line,
|
|
922
|
+
},
|
|
923
|
+
codeBefore: ctx.codeBefore,
|
|
924
|
+
codeAfter: ctx.codeAfter,
|
|
925
|
+
...(ctx.diffAtAnchor !== undefined ? { diffAtAnchor: ctx.diffAtAnchor } : {}),
|
|
926
|
+
};
|
|
927
|
+
});
|
|
928
|
+
// ---- Define verifier (Anthropic Messages API via raw fetch) -----------
|
|
929
|
+
// Model is hardcoded here — the verifier needs a model that handles
|
|
930
|
+
// structured JSON output reliably. Sonnet 4.6 is the default. Override
|
|
931
|
+
// via CLUD_BUG_VERIFIER_MODEL env var if needed.
|
|
932
|
+
const verifierModel = String(process.env.CLUD_BUG_VERIFIER_MODEL ?? '').trim()
|
|
933
|
+
|| 'claude-sonnet-4-6';
|
|
934
|
+
async function verifier(input) {
|
|
935
|
+
const userPrompt = buildVerifierPrompt(input);
|
|
936
|
+
try {
|
|
937
|
+
const resp = await fetch('https://api.anthropic.com/v1/messages', {
|
|
938
|
+
method: 'POST',
|
|
939
|
+
headers: {
|
|
940
|
+
'content-type': 'application/json',
|
|
941
|
+
'anthropic-version': '2023-06-01',
|
|
942
|
+
'x-api-key': anthropicKey,
|
|
943
|
+
},
|
|
944
|
+
body: JSON.stringify({
|
|
945
|
+
model: verifierModel,
|
|
946
|
+
max_tokens: 300,
|
|
947
|
+
system: VERIFIER_SYSTEM,
|
|
948
|
+
messages: [{ role: 'user', content: userPrompt }],
|
|
949
|
+
}),
|
|
950
|
+
});
|
|
951
|
+
if (!resp.ok) {
|
|
952
|
+
const errText = await resp.text().catch(() => '');
|
|
953
|
+
return {
|
|
954
|
+
verdict: 'UNCERTAIN',
|
|
955
|
+
source: 'api-error',
|
|
956
|
+
rationale: `Anthropic API ${resp.status}: ${errText.slice(0, 200)}`,
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
const body = await resp.json();
|
|
960
|
+
// Messages API: content is an array of blocks; pick the first text block.
|
|
961
|
+
const text = (body?.content ?? []).find((b) => b?.type === 'text')?.text ?? '';
|
|
962
|
+
return parseVerifierResponse(text);
|
|
963
|
+
}
|
|
964
|
+
catch (err) {
|
|
965
|
+
return {
|
|
966
|
+
verdict: 'UNCERTAIN',
|
|
967
|
+
source: 'api-error',
|
|
968
|
+
rationale: `verifier fetch error: ${err?.message ?? String(err)}`,
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
// ---- Resolve auth (Wave 5b.1: PAT optional + graceful fallback) -------
|
|
973
|
+
// A dedicated PAT lets us actually close threads; without one the Actions
|
|
974
|
+
// GITHUB_TOKEN can't resolve, so we post a "verified fixed" reply only.
|
|
975
|
+
const auth = selectResolveAuth(process.env);
|
|
976
|
+
// ---- Idempotency: skip threads whose anchor is unchanged --------------
|
|
977
|
+
// For each candidate, hash the post-fix anchor and look for a prior bot
|
|
978
|
+
// reply carrying the same (verdict, sig) marker. If the anchor hasn't moved
|
|
979
|
+
// since that marker, we re-use the cached verdict — NO verifier call, NO
|
|
980
|
+
// re-post — which kills repeat replies on multi-push PRs and saves spend.
|
|
981
|
+
const verifyItems = []; // { thread, sig } — need (re)verification
|
|
982
|
+
const cachedItems = []; // { thread, action } — anchor unchanged, no I/O
|
|
983
|
+
for (let i = 0; i < priorThreads.length; i++) {
|
|
984
|
+
const thread = priorThreads[i];
|
|
985
|
+
const candidate = candidates[i];
|
|
986
|
+
if (!thread)
|
|
987
|
+
continue;
|
|
988
|
+
const sig = anchorSignature(thread.finding, thread.codeAfter);
|
|
989
|
+
const prior = latestResolveMarker(candidate?.comments ?? [], BOT_AUTHORS);
|
|
990
|
+
if (prior && prior.sig === sig) {
|
|
991
|
+
const action = applyResolutionRules({
|
|
992
|
+
thread,
|
|
993
|
+
verdict: {
|
|
994
|
+
verdict: prior.verdict,
|
|
995
|
+
source: 'model',
|
|
996
|
+
rationale: '(unchanged since last check)',
|
|
997
|
+
},
|
|
998
|
+
config: autoResolveConfig,
|
|
999
|
+
canResolve: auth.hasDedicatedPat,
|
|
1000
|
+
});
|
|
1001
|
+
cachedItems.push({ thread, action });
|
|
1002
|
+
}
|
|
1003
|
+
else {
|
|
1004
|
+
verifyItems.push({ thread, sig });
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
// ---- Run pure orchestration on the threads that need (re)verification --
|
|
1008
|
+
const result = await runAutoResolve({
|
|
1009
|
+
priorThreads: verifyItems.map((v) => v.thread),
|
|
1010
|
+
config: autoResolveConfig,
|
|
1011
|
+
verifier,
|
|
1012
|
+
canResolve: auth.hasDedicatedPat,
|
|
1013
|
+
});
|
|
1014
|
+
let shouldRequestChanges = result.shouldRequestChanges;
|
|
1015
|
+
for (const c of cachedItems) {
|
|
1016
|
+
if (c.action.kind === 'keep_open_request_changes')
|
|
1017
|
+
shouldRequestChanges = true;
|
|
1018
|
+
}
|
|
1019
|
+
// ---- Execute the actions via GraphQL mutations ------------------------
|
|
1020
|
+
const actionsReport = [];
|
|
1021
|
+
// Unchanged threads: never re-post the reply (idempotent). BUT if the
|
|
1022
|
+
// thread is verified ADDRESSED and a PAT is now available, resolve it —
|
|
1023
|
+
// the marker reply already exists, so this closes a thread that couldn't
|
|
1024
|
+
// be resolved before (no-PAT → PAT upgrade, or a prior transient
|
|
1025
|
+
// resolve-failure) WITHOUT re-verifying or re-replying.
|
|
1026
|
+
for (const c of cachedItems) {
|
|
1027
|
+
const baseReport = {
|
|
1028
|
+
threadId: c.thread.threadId,
|
|
1029
|
+
file: c.thread.finding.file,
|
|
1030
|
+
line: c.thread.finding.line,
|
|
1031
|
+
verdict: c.action.verdict?.verdict,
|
|
1032
|
+
kind: c.action.kind,
|
|
1033
|
+
};
|
|
1034
|
+
if (c.action.kind === 'resolve' && auth.hasDedicatedPat) {
|
|
1035
|
+
const resolveResult = spawnSync('gh', [
|
|
1036
|
+
'api', 'graphql',
|
|
1037
|
+
'-f', `query=${RESOLVE_THREAD_MUTATION}`,
|
|
1038
|
+
'-f', `threadId=${c.thread.threadId}`,
|
|
1039
|
+
], { encoding: 'utf8', env: { ...process.env, GH_TOKEN: auth.token } });
|
|
1040
|
+
if (resolveResult.status !== 0) {
|
|
1041
|
+
process.stderr.write(`clud-bug resolve-threads: RESOLVE (cached) failed for thread ${c.thread.threadId}: ${(resolveResult.stderr || '').slice(0, 200)}\n`);
|
|
1042
|
+
actionsReport.push({ ...baseReport, executed: 'resolve-failed' });
|
|
1043
|
+
}
|
|
1044
|
+
else {
|
|
1045
|
+
actionsReport.push({ ...baseReport, executed: 'resolved-from-cache' });
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
else {
|
|
1049
|
+
actionsReport.push({ ...baseReport, executed: 'unchanged' });
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
for (let i = 0; i < result.actions.length; i++) {
|
|
1053
|
+
const action = result.actions[i];
|
|
1054
|
+
const item = verifyItems[i];
|
|
1055
|
+
if (!action || !item)
|
|
1056
|
+
continue;
|
|
1057
|
+
const thread = item.thread;
|
|
1058
|
+
const report = {
|
|
1059
|
+
threadId: thread.threadId,
|
|
1060
|
+
file: thread.finding.file,
|
|
1061
|
+
line: thread.finding.line,
|
|
1062
|
+
verdict: action.verdict?.verdict,
|
|
1063
|
+
kind: action.kind,
|
|
1064
|
+
};
|
|
1065
|
+
if (action.kind === 'skipped') {
|
|
1066
|
+
actionsReport.push({ ...report, executed: 'skipped' });
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
1069
|
+
// Reply body carries a hidden idempotency marker (verdict + anchor sig)
|
|
1070
|
+
// so a later fix-push won't re-reply while the anchor is unchanged.
|
|
1071
|
+
const tag = renderResolveMarkerTag(action.verdict.verdict, item.sig);
|
|
1072
|
+
const replyBody = `${action.markerBody}\n\n${tag}`;
|
|
1073
|
+
const replyResult = spawnSync('gh', [
|
|
1074
|
+
'api', 'graphql',
|
|
1075
|
+
'-f', `query=${ADD_REPLY_MUTATION}`,
|
|
1076
|
+
'-f', `threadId=${thread.threadId}`,
|
|
1077
|
+
'-f', `body=${replyBody}`,
|
|
1078
|
+
], { encoding: 'utf8' });
|
|
1079
|
+
if (replyResult.status !== 0) {
|
|
1080
|
+
process.stderr.write(`clud-bug resolve-threads: ADD_REPLY failed for thread ${thread.threadId}: ${(replyResult.stderr || '').slice(0, 200)}\n`);
|
|
1081
|
+
actionsReport.push({ ...report, executed: 'reply-failed' });
|
|
1082
|
+
continue;
|
|
1083
|
+
}
|
|
1084
|
+
// Resolve only ADDRESSED threads, and only with a dedicated PAT — the
|
|
1085
|
+
// Actions GITHUB_TOKEN can't resolve ("Resource not accessible by
|
|
1086
|
+
// integration"). No PAT → marker-reply-only (graceful, no error).
|
|
1087
|
+
if (action.kind === 'resolve') {
|
|
1088
|
+
if (!auth.hasDedicatedPat) {
|
|
1089
|
+
actionsReport.push({ ...report, executed: 'reply-only-no-pat' });
|
|
1090
|
+
continue;
|
|
1091
|
+
}
|
|
1092
|
+
const resolveResult = spawnSync('gh', [
|
|
1093
|
+
'api', 'graphql',
|
|
1094
|
+
'-f', `query=${RESOLVE_THREAD_MUTATION}`,
|
|
1095
|
+
'-f', `threadId=${thread.threadId}`,
|
|
1096
|
+
],
|
|
1097
|
+
// Scope the PAT to JUST this call; all other gh calls keep GH_TOKEN.
|
|
1098
|
+
{ encoding: 'utf8', env: { ...process.env, GH_TOKEN: auth.token } });
|
|
1099
|
+
if (resolveResult.status !== 0) {
|
|
1100
|
+
process.stderr.write(`clud-bug resolve-threads: RESOLVE failed for thread ${thread.threadId}: ${(resolveResult.stderr || '').slice(0, 200)}\n`);
|
|
1101
|
+
actionsReport.push({ ...report, executed: 'resolve-failed' });
|
|
1102
|
+
continue;
|
|
1103
|
+
}
|
|
1104
|
+
actionsReport.push({ ...report, executed: 'resolved' });
|
|
1105
|
+
}
|
|
1106
|
+
else {
|
|
1107
|
+
// keep_open or keep_open_request_changes — reply already posted.
|
|
1108
|
+
actionsReport.push({ ...report, executed: 'kept-open' });
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
process.stdout.write(JSON.stringify({
|
|
1112
|
+
actions: actionsReport,
|
|
1113
|
+
verifierCallCount: result.verifierCallCount,
|
|
1114
|
+
shouldRequestChanges,
|
|
1115
|
+
resolveTokenSource: auth.source,
|
|
1116
|
+
}) + '\n');
|
|
1117
|
+
}
|
|
343
1118
|
// 0.0.E (v0.6.17): thin wrapper around the golden-set test file. Devs
|
|
344
1119
|
// who follow the README invoke `clud-bug eval` — this routes to the
|
|
345
1120
|
// same `node --test` runner CI uses, so dev and CI verdicts match.
|
|
@@ -421,27 +1196,114 @@ async function runInit(args) {
|
|
|
421
1196
|
language: templateLanguage(tmplName),
|
|
422
1197
|
}),
|
|
423
1198
|
});
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
//
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
1199
|
+
// --local-only (max mode): SKIP the GitHub Action workflows entirely. The
|
|
1200
|
+
// review + audit workflows run `claude-code-action` with ANTHROPIC_API_KEY
|
|
1201
|
+
// (per-token cost); max mode reviews on the session's own subscription, so a
|
|
1202
|
+
// local-only install must never add a token-billing Action. (self-update is
|
|
1203
|
+
// benign but skipped too — local-only means no clud-bug CI workflows at all.)
|
|
1204
|
+
if (args.localOnly) {
|
|
1205
|
+
log(' skipped GitHub Action workflows (--local-only: max mode runs on the session subscription, no API key)');
|
|
1206
|
+
}
|
|
1207
|
+
else {
|
|
1208
|
+
const workflowPath = join(cwd, '.github', 'workflows', 'clud-bug-review.yml');
|
|
1209
|
+
await mkdir(dirname(workflowPath), { recursive: true });
|
|
1210
|
+
await writeFile(workflowPath, workflow);
|
|
1211
|
+
log(` wrote ${rel(cwd, workflowPath)}`);
|
|
1212
|
+
// Install the audit workflow alongside the per-PR review one.
|
|
1213
|
+
// Manual-trigger by default; users opt into the cron by uncommenting.
|
|
1214
|
+
// Routed through renderFile so {{CCA_VERSION}} substitution pins
|
|
1215
|
+
// claude-code-action consistently with the review workflow.
|
|
1216
|
+
const auditTmpl = await renderFile(join(TEMPLATES, 'audit.yml.tmpl'), {});
|
|
1217
|
+
const auditPath = join(cwd, '.github', 'workflows', 'clud-bug-audit.yml');
|
|
1218
|
+
await writeFile(auditPath, auditTmpl);
|
|
1219
|
+
log(` wrote ${rel(cwd, auditPath)}`);
|
|
1220
|
+
// Install the self-update workflow. Cron weekly Mondays 12:00 UTC; opens
|
|
1221
|
+
// a PR if a newer clud-bug version is published. Disable by deleting the
|
|
1222
|
+
// file or pinning via .claude/skills/.clud-bug.json.
|
|
1223
|
+
// Routed through renderFile for parity (no CCA ref today but future
|
|
1224
|
+
// tokens should propagate uniformly).
|
|
1225
|
+
const selfUpdateTmpl = await renderFile(join(TEMPLATES, 'self-update.yml.tmpl'), {});
|
|
1226
|
+
const selfUpdatePath = join(cwd, '.github', 'workflows', 'clud-bug-self-update.yml');
|
|
1227
|
+
await writeFile(selfUpdatePath, selfUpdateTmpl);
|
|
1228
|
+
log(` wrote ${rel(cwd, selfUpdatePath)}`);
|
|
1229
|
+
}
|
|
1230
|
+
// v0.7.0 (Wave 6b): optional local-review slash command. Scaffolds
|
|
1231
|
+
// `.claude/commands/clud-bug-review.md` so `/clud-bug-review` works inside a
|
|
1232
|
+
// Claude Code session — the agent reviews the current branch's open PR using
|
|
1233
|
+
// THAT session's own tokens (Max or API), no hosted App or new auth required.
|
|
1234
|
+
// `.claude/` is already in the --commit add-list below, so it's staged too.
|
|
1235
|
+
// `--with-hooks` implies `--with-local-review` — the auto hook and the manual
|
|
1236
|
+
// `/clud-bug-review` command are complementary (the hook auto-runs the engine
|
|
1237
|
+
// recipe on each commit; the command runs it on demand against the PR).
|
|
1238
|
+
// --local-only IS the max-mode install: the slash command + the commit hook,
|
|
1239
|
+
// minus the GitHub Action workflows (gated above).
|
|
1240
|
+
if (args.localOnly) {
|
|
1241
|
+
args.withLocalReview = true;
|
|
1242
|
+
args.withHooks = true;
|
|
1243
|
+
}
|
|
1244
|
+
if (args.withHooks)
|
|
1245
|
+
args.withLocalReview = true;
|
|
1246
|
+
if (args.withLocalReview) {
|
|
1247
|
+
const commandPath = join(cwd, '.claude', 'commands', 'clud-bug-review.md');
|
|
1248
|
+
await mkdir(dirname(commandPath), { recursive: true });
|
|
1249
|
+
const commandContent = await renderFile(join(TEMPLATES, 'clud-bug-review.md.tmpl'), {});
|
|
1250
|
+
await writeFile(commandPath, commandContent);
|
|
1251
|
+
log(` wrote ${rel(cwd, commandPath)}`);
|
|
1252
|
+
}
|
|
1253
|
+
// v0.7.0 (Wave 6b; rc.17 fix): optional native commit-review hook. Merges a
|
|
1254
|
+
// Claude Code `type: command` PostToolUse hook into `.claude/settings.json`
|
|
1255
|
+
// (preserving any existing settings + hooks) that, on every `git commit`, runs
|
|
1256
|
+
// `clud-bug review-prompt` and surfaces the recipe back to the session so the
|
|
1257
|
+
// agent reviews the commit on the session's subscription, in the background.
|
|
1258
|
+
// (`type: command`, not `type: agent` — agent hooks get no Bash, so they could
|
|
1259
|
+
// never run the CLI; see hooks.ts.)
|
|
1260
|
+
if (args.withHooks) {
|
|
1261
|
+
const { mergeLocalReviewHook, buildCommitReviewCommand } = await import('./hooks.js');
|
|
1262
|
+
// Floating @next pin (default) — the hook auto-fetches the latest recipe.
|
|
1263
|
+
const hookCommand = buildCommitReviewCommand();
|
|
1264
|
+
const settingsPath = join(cwd, '.claude', 'settings.json');
|
|
1265
|
+
await mkdir(dirname(settingsPath), { recursive: true });
|
|
1266
|
+
// Read-then-parse so we can tell "no file yet" (fresh merge) from "file
|
|
1267
|
+
// exists but is invalid JSON" (leave it ALONE — never clobber user content).
|
|
1268
|
+
let raw;
|
|
1269
|
+
try {
|
|
1270
|
+
raw = await readFile(settingsPath, 'utf8');
|
|
1271
|
+
}
|
|
1272
|
+
catch {
|
|
1273
|
+
raw = undefined; // no settings.json yet → fresh
|
|
1274
|
+
}
|
|
1275
|
+
let existing;
|
|
1276
|
+
let proceed = true;
|
|
1277
|
+
if (raw !== undefined) {
|
|
1278
|
+
try {
|
|
1279
|
+
existing = JSON.parse(raw);
|
|
1280
|
+
}
|
|
1281
|
+
catch {
|
|
1282
|
+
proceed = false;
|
|
1283
|
+
log(` skipped ${rel(cwd, settingsPath)} (existing file is not valid JSON; left untouched)`);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
if (proceed) {
|
|
1287
|
+
const merged = mergeLocalReviewHook(existing, hookCommand);
|
|
1288
|
+
await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n');
|
|
1289
|
+
log(` wrote ${rel(cwd, settingsPath)} (commit-review hook)`);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
// rc.16: --with-design installs the bundled design-critic kit (3 `kind:
|
|
1293
|
+
// design` skills) and enables the off-by-default design lens. writeSkills
|
|
1294
|
+
// writes the SKILL.md files + merges the manifest entries; the `design` block
|
|
1295
|
+
// is flipped on the manifest read below so the local recipe + hosted bot both
|
|
1296
|
+
// run the visual pass. Idempotent — re-runs replace the skills in place.
|
|
1297
|
+
if (args.withDesign) {
|
|
1298
|
+
const designKit = await loadDesignKit(DESIGN_DIR);
|
|
1299
|
+
if (designKit.length > 0) {
|
|
1300
|
+
await writeSkills(join(cwd, '.claude', 'skills'), designKit, client);
|
|
1301
|
+
log(` pinned ${designKit.length} design specimens (visual review lens enabled)`);
|
|
1302
|
+
}
|
|
1303
|
+
else {
|
|
1304
|
+
warn('No design-kit skills found to install (templates/skills/design/ empty?).');
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
445
1307
|
// Stamp the manifest. Sets strictMode: true ONLY on fresh installs —
|
|
446
1308
|
// a manifest that's never been touched by clud-bug init/update has no
|
|
447
1309
|
// lastUpdate field. Existing v0.3.x advisory installs (where strictMode
|
|
@@ -450,6 +1312,12 @@ async function runInit(args) {
|
|
|
450
1312
|
// fresh inits. Users opt out by setting strictMode: false.
|
|
451
1313
|
const skillsDirPath = join(cwd, '.claude', 'skills');
|
|
452
1314
|
const manifest = await readManifest(skillsDirPath);
|
|
1315
|
+
if (args.withDesign) {
|
|
1316
|
+
// Flip the off-by-default design lens on; preserve any existing knobs
|
|
1317
|
+
// (gate / themes / viewports) the user already set.
|
|
1318
|
+
const prior = manifest.design ?? {};
|
|
1319
|
+
manifest.design = { ...prior, enabled: true };
|
|
1320
|
+
}
|
|
453
1321
|
const isFreshInstall = manifest.lastUpdate === undefined;
|
|
454
1322
|
manifest.lastUpdateVersion = await readPkgVersion();
|
|
455
1323
|
manifest.lastUpdate = new Date().toISOString();
|
|
@@ -479,9 +1347,14 @@ async function runInit(args) {
|
|
|
479
1347
|
log(' committing...');
|
|
480
1348
|
const toAdd = [
|
|
481
1349
|
'.claude',
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
1350
|
+
// No GitHub Action workflows under --local-only (none were written).
|
|
1351
|
+
...(args.localOnly
|
|
1352
|
+
? []
|
|
1353
|
+
: [
|
|
1354
|
+
'.github/workflows/clud-bug-review.yml',
|
|
1355
|
+
'.github/workflows/clud-bug-audit.yml',
|
|
1356
|
+
'.github/workflows/clud-bug-self-update.yml',
|
|
1357
|
+
]),
|
|
485
1358
|
...agentDocs.created,
|
|
486
1359
|
...agentDocs.touched,
|
|
487
1360
|
];
|
|
@@ -496,22 +1369,44 @@ async function runInit(args) {
|
|
|
496
1369
|
await runInitBranchProtection(args);
|
|
497
1370
|
log('');
|
|
498
1371
|
log('Field kit assembled. Next:');
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
log('
|
|
503
|
-
|
|
1372
|
+
if (args.localOnly) {
|
|
1373
|
+
log(' Max mode — reviews run inside your Claude Code session on your own');
|
|
1374
|
+
log(' subscription. No API key, no GitHub Action, no per-review bill. On every');
|
|
1375
|
+
log(' commit the hook surfaces a review recipe; or run /clud-bug-review on demand.');
|
|
1376
|
+
if (!args.commit) {
|
|
1377
|
+
log(' → git add .claude && git commit && git push');
|
|
1378
|
+
}
|
|
1379
|
+
else {
|
|
1380
|
+
log(' → git push.');
|
|
1381
|
+
}
|
|
504
1382
|
}
|
|
505
1383
|
else {
|
|
506
|
-
log('
|
|
1384
|
+
log(' 1. Set ANTHROPIC_API_KEY in your repo secrets:');
|
|
1385
|
+
log(' Settings → Secrets and variables → Actions → New repository secret');
|
|
1386
|
+
if (!args.commit) {
|
|
1387
|
+
log(' 2. git add .claude .github/workflows/clud-bug-*.yml && git commit && git push');
|
|
1388
|
+
log(' 3. Open a PR — the naturalist arrives within ~2 minutes.');
|
|
1389
|
+
}
|
|
1390
|
+
else {
|
|
1391
|
+
log(' 2. git push, then open a PR — the naturalist arrives within ~2 minutes.');
|
|
1392
|
+
}
|
|
507
1393
|
}
|
|
508
1394
|
log('');
|
|
509
1395
|
log('Drop your own .claude/skills/<name>/SKILL.md files anytime — they get pinned automatically.');
|
|
510
|
-
|
|
511
|
-
|
|
1396
|
+
// These reference the GitHub Action workflows, which --local-only does not write.
|
|
1397
|
+
if (!args.localOnly) {
|
|
1398
|
+
log('For a whole-repo walk: Actions tab → Clud Bug 🐛 Audit → Run workflow.');
|
|
1399
|
+
log('Self-update is on (weekly Mondays 12:00 UTC). Pin via "pinVersion" in .claude/skills/.clud-bug.json.');
|
|
1400
|
+
}
|
|
512
1401
|
log('');
|
|
513
|
-
|
|
514
|
-
|
|
1402
|
+
if (args.localOnly) {
|
|
1403
|
+
log('Strict mode + branch-protection enforcement apply to the GitHub Action path; max mode is');
|
|
1404
|
+
log('advisory by design — findings surface in your session for you to act on.');
|
|
1405
|
+
}
|
|
1406
|
+
else {
|
|
1407
|
+
log('Strict mode is ON by default (clud-bug-review fails the check on critical findings).');
|
|
1408
|
+
log(' • Add `clud-bug-review` to your branch protection required checks for full enforcement.');
|
|
1409
|
+
}
|
|
515
1410
|
log(' • Opt out by setting "strictMode": false in .claude/skills/.clud-bug.json.');
|
|
516
1411
|
// v0.6.33 — opt-in unified install (mirror of logmind v0.6.8). When
|
|
517
1412
|
// --with-skdd is passed, subprocess to `pip install logmind` + `logmind init`
|
|
@@ -958,6 +1853,25 @@ async function runUpdateCmd(_args) {
|
|
|
958
1853
|
log('Commit + push to apply the refreshed kit on the next PR.');
|
|
959
1854
|
ok(`updated: @v${ourVersion}, ${result.changed.length} changed, ${result.unchanged.length} unchanged${skipped.length ? `, ${skipped.length} skipped` : ''}`);
|
|
960
1855
|
}
|
|
1856
|
+
// v0.7.0-rc.4 — `clud-bug configure-github <owner>/<repo>`. Pulls in the
|
|
1857
|
+
// SPEC §7 canonical ruleset applier from src/cli/configure-github.ts;
|
|
1858
|
+
// thin wrapper here just maps CLI args into the typed entry point. The
|
|
1859
|
+
// command is idempotent — re-runs on a converged repo exit 0 with no
|
|
1860
|
+
// PATCH calls. See `src/core/configure-github.ts` for the diff + rule
|
|
1861
|
+
// table.
|
|
1862
|
+
async function runConfigureGithubCmd(args) {
|
|
1863
|
+
const { runConfigureGithub } = await import('./configure-github.js');
|
|
1864
|
+
const target = args._[1] ?? null;
|
|
1865
|
+
const code = await runConfigureGithub({
|
|
1866
|
+
target,
|
|
1867
|
+
branch: args.branch || 'main',
|
|
1868
|
+
dryRun: Boolean(args.dryRun),
|
|
1869
|
+
quiet: QUIET,
|
|
1870
|
+
json: Boolean(args.json),
|
|
1871
|
+
});
|
|
1872
|
+
if (code !== 0)
|
|
1873
|
+
process.exit(code);
|
|
1874
|
+
}
|
|
961
1875
|
async function runAudit(args) {
|
|
962
1876
|
const cwd = process.cwd();
|
|
963
1877
|
const date = new Date().toISOString().slice(0, 10);
|