@kurokeita/add-skill 2.4.0 → 2.5.1

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.
@@ -0,0 +1,142 @@
1
+ import type { SessionMessage } from "claude-code"
2
+
3
+ import type { CheckState, Step } from "../types"
4
+
5
+ export type Platform = "github" | "azure"
6
+
7
+ export function platformOf(remote: string): Platform | null {
8
+ if (/github\.com[:/]/.test(remote)) return "github"
9
+ if (/dev\.azure\.com[:/]|\.visualstudio\.com\//.test(remote)) return "azure"
10
+ return null
11
+ }
12
+
13
+ export function repoNameOf(remote: string): string {
14
+ return (
15
+ remote
16
+ .trim()
17
+ .replace(/\.git$/, "")
18
+ .split(/[/:]/)
19
+ .pop() ?? ""
20
+ ).toLowerCase()
21
+ }
22
+
23
+ function decoded(name: string): string {
24
+ try {
25
+ return decodeURIComponent(name).toLowerCase()
26
+ } catch {
27
+ return name.toLowerCase()
28
+ }
29
+ }
30
+
31
+ // PR links anywhere in the conversation: a command's output (gh pr create and
32
+ // gh pr edit print the URL), the model's own text, or a link the person pasted.
33
+ // Only links into this repository count, since the fetch runs against it.
34
+ export function prNumbersIn(
35
+ messages: readonly SessionMessage[],
36
+ platform: Platform,
37
+ repo: string,
38
+ ): number[] {
39
+ const pattern =
40
+ platform === "github"
41
+ ? /github\.com\/[\w.-]+\/([\w.-]+)\/pull\/(\d{1,9})/g
42
+ : /_git\/([^/\s?#"']+)\/pullrequest\/(\d{1,9})/gi
43
+ const found = new Set<number>()
44
+ for (const message of messages) {
45
+ const texts = [message.text]
46
+ for (const use of message.toolUses)
47
+ texts.push(JSON.stringify(use.input), use.text ?? "")
48
+ for (const result of message.toolResults ?? []) texts.push(result.text)
49
+ for (const text of texts) {
50
+ for (const match of text.matchAll(pattern)) {
51
+ if (decoded(match[1] ?? "") === repo) found.add(Number(match[2]))
52
+ }
53
+ }
54
+ }
55
+ return [...found]
56
+ }
57
+
58
+ // Items a branch without a PR points at: a 3-9 digit segment of its name (plus
59
+ // gh issue develop's "<n>-slug"), then AB#N or #N in its own commits. 9 digits
60
+ // at most, as both APIs take 32-bit ids and one bad literal fails the query.
61
+ export function itemRefs(
62
+ platform: Platform,
63
+ branch: string,
64
+ commitLog: string,
65
+ ): number[] {
66
+ const refs: string[] = []
67
+ const lead =
68
+ platform === "github" ? /^(\d{1,9})-/.exec(branch)?.[1] : undefined
69
+ if (lead) refs.push(lead)
70
+ refs.push(
71
+ ...branch.split(/[^A-Za-z0-9]+/).filter((part) => /^\d{3,9}$/.test(part)),
72
+ )
73
+ const pattern = platform === "github" ? /#(\d{1,9})/g : /AB#(\d{1,9})/g
74
+ for (const match of commitLog.matchAll(pattern)) refs.push(match[1] ?? "")
75
+ return [...new Set(refs.filter(Boolean).map(Number))].slice(0, 10)
76
+ }
77
+
78
+ export function githubChecks(rollup: string | null | undefined): CheckState {
79
+ if (rollup === "SUCCESS") return "passing"
80
+ if (rollup === "FAILURE" || rollup === "ERROR") return "failing"
81
+ if (rollup === "PENDING" || rollup === "EXPECTED") return "pending"
82
+ return "none"
83
+ }
84
+
85
+ export function azureChecks(statuses: readonly string[]): CheckState {
86
+ if (statuses.some((s) => s === "rejected" || s === "broken")) return "failing"
87
+ if (statuses.some((s) => s === "running" || s === "queued")) return "pending"
88
+ if (statuses.some((s) => s === "approved")) return "passing"
89
+ return "none"
90
+ }
91
+
92
+ export function azureReview(votes: readonly number[]): string {
93
+ if (votes.includes(-10)) return "rejected"
94
+ if (votes.includes(-5)) return "waiting for author"
95
+ if (votes.some((vote) => vote >= 5)) return "approved"
96
+ return ""
97
+ }
98
+
99
+ export const SUMMARY_SYSTEM = `You summarize a coding session for a side panel.
100
+ Reply with JSON only, no prose and no code fence:
101
+ {"goal": "<2-4 sentences: what the session is trying to achieve and where it stands now>",
102
+ "steps": [{"text": "<one short line>", "status": "done" | "current" | "todo"}]}
103
+ Steps run in order, at most 12, and at most one is "current".`
104
+
105
+ // The newest part of the conversation that fits the model's budget.
106
+ export function transcriptOf(messages: readonly SessionMessage[]): string {
107
+ const lines = messages.map((m) => {
108
+ const tools = m.toolUses.map(
109
+ (use) => `[${use.tool}] ${JSON.stringify(use.input).slice(0, 200)}`,
110
+ )
111
+ return `${m.role}: ${[m.text.slice(0, 2000), ...tools].filter(Boolean).join("\n")}`
112
+ })
113
+ return lines.join("\n\n").slice(-60_000)
114
+ }
115
+
116
+ export function parseSummary(
117
+ text: string,
118
+ ): { goal: string; steps: Step[] } | null {
119
+ const start = text.indexOf("{")
120
+ const end = text.lastIndexOf("}")
121
+ if (start < 0 || end < start) return null
122
+ try {
123
+ const value = JSON.parse(text.slice(start, end + 1)) as {
124
+ goal?: unknown
125
+ steps?: unknown
126
+ }
127
+ if (typeof value.goal !== "string" || !Array.isArray(value.steps))
128
+ return null
129
+ const steps: Step[] = []
130
+ for (const step of value.steps as { text?: unknown; status?: unknown }[]) {
131
+ if (typeof step?.text !== "string") continue
132
+ const status =
133
+ step.status === "done" || step.status === "current"
134
+ ? step.status
135
+ : "todo"
136
+ steps.push({ text: step.text, status })
137
+ }
138
+ return { goal: value.goal, steps }
139
+ } catch {
140
+ return null
141
+ }
142
+ }
@@ -0,0 +1,327 @@
1
+ import type { Item, Pr, Work } from "../types"
2
+ import {
3
+ azureChecks,
4
+ azureReview,
5
+ githubChecks,
6
+ itemRefs,
7
+ type Platform,
8
+ } from "./discover"
9
+
10
+ // Runs a command; register.tsx supplies it, since only that file may use $.
11
+ export type Run = (
12
+ argv: string[],
13
+ ) => Promise<{ exitCode: number; stdout: string; stderr: string }>
14
+
15
+ export async function git(run: Run, args: string[]): Promise<string> {
16
+ const { exitCode, stdout } = await run(["git", ...args])
17
+ return exitCode === 0 ? stdout.trim() : ""
18
+ }
19
+
20
+ function firstLine(text: string): string {
21
+ return text.trim().split("\n")[0] ?? ""
22
+ }
23
+
24
+ async function refsByBranch(
25
+ run: Run,
26
+ platform: Platform,
27
+ branches: readonly string[],
28
+ ) {
29
+ const base = await git(run, [
30
+ "symbolic-ref",
31
+ "--short",
32
+ "refs/remotes/origin/HEAD",
33
+ ])
34
+ const refs = new Map<string, number[]>()
35
+ for (const branch of branches) {
36
+ const log = base
37
+ ? await git(run, ["log", "--format=%B", `${base}..${branch}`])
38
+ : ""
39
+ refs.set(branch, itemRefs(platform, branch, log))
40
+ }
41
+ return refs
42
+ }
43
+
44
+ const PR_FIELDS = `number title url state isDraft reviewDecision headRefName
45
+ comments { totalCount } reviewThreads { totalCount }
46
+ commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }
47
+ closingIssuesReferences(first: 10) { nodes { number title url state } }`
48
+
49
+ type GhIssue = { number: number; title: string; url: string; state: string }
50
+ type GhPr = {
51
+ number: number
52
+ title: string
53
+ url: string
54
+ state: string
55
+ isDraft: boolean
56
+ reviewDecision: string | null
57
+ headRefName: string
58
+ comments: { totalCount: number }
59
+ reviewThreads: { totalCount: number }
60
+ commits: {
61
+ nodes: { commit: { statusCheckRollup: { state: string } | null } }[]
62
+ }
63
+ closingIssuesReferences: { nodes: GhIssue[] }
64
+ }
65
+
66
+ const ghItem = (issue: GhIssue): Item => ({
67
+ id: issue.number,
68
+ url: issue.url,
69
+ title: issue.title,
70
+ state: issue.state.toLowerCase(),
71
+ })
72
+
73
+ // One GraphQL call: each branch's newest PR, each PR the conversation linked,
74
+ // and every item ref, as aliases. Branch names go in as variables, never into
75
+ // the query text. gh exits 1 when an alias does not resolve yet still prints
76
+ // the rest, so success is judged by data.repository.
77
+ export async function fetchGitHub(
78
+ run: Run,
79
+ branches: readonly string[],
80
+ prNumbers: readonly number[],
81
+ ): Promise<Work> {
82
+ const refs = await refsByBranch(run, "github", branches)
83
+ const vars: string[] = []
84
+ const fields: string[] = []
85
+ const args: string[] = []
86
+ branches.forEach((branch, i) => {
87
+ vars.push(`, $b${i}: String!`)
88
+ args.push("-f", `b${i}=${branch}`)
89
+ fields.push(`b${i}: pullRequests(headRefName: $b${i}, first: 1, states: [OPEN, MERGED, CLOSED],
90
+ orderBy: {field: CREATED_AT, direction: DESC}) { nodes { ...P } }`)
91
+ })
92
+ for (const n of prNumbers)
93
+ fields.push(`p${n}: pullRequest(number: ${n}) { ...P }`)
94
+ for (const n of new Set([...refs.values()].flat())) {
95
+ fields.push(
96
+ `i${n}: issueOrPullRequest(number: ${n}) { ... on Issue { number title url state } }`,
97
+ )
98
+ }
99
+ if (fields.length === 0) return { prs: [], items: [], fetchedAt: Date.now() }
100
+ const query = `query($owner: String!, $name: String!${vars.join("")}) {
101
+ repository(owner: $owner, name: $name) { ${fields.join("\n")} }
102
+ } fragment P on PullRequest { ${PR_FIELDS} }`
103
+ const out = await run([
104
+ "gh",
105
+ "api",
106
+ "graphql",
107
+ "-F",
108
+ "owner={owner}",
109
+ "-F",
110
+ "name={repo}",
111
+ ...args,
112
+ "-f",
113
+ `query=${query}`,
114
+ ])
115
+ let repo: Record<string, unknown> | undefined
116
+ try {
117
+ repo = (
118
+ JSON.parse(out.stdout) as {
119
+ data?: { repository?: Record<string, unknown> }
120
+ }
121
+ ).data?.repository
122
+ } catch {
123
+ repo = undefined
124
+ }
125
+ if (!repo)
126
+ return {
127
+ prs: [],
128
+ items: [],
129
+ fetchedAt: Date.now(),
130
+ error: `gh: ${firstLine(out.stderr) || "no data"}`,
131
+ }
132
+
133
+ const prs = new Map<number, Pr>()
134
+ const items = new Map<number, Item>()
135
+ const addPr = (pr: GhPr) => {
136
+ prs.set(pr.number, {
137
+ number: pr.number,
138
+ url: pr.url,
139
+ title: pr.title,
140
+ branch: pr.headRefName,
141
+ state:
142
+ pr.isDraft && pr.state === "OPEN" ? "draft" : pr.state.toLowerCase(),
143
+ review: (pr.reviewDecision ?? "").toLowerCase().replace(/_/g, " "),
144
+ checks: githubChecks(
145
+ pr.commits.nodes[0]?.commit.statusCheckRollup?.state,
146
+ ),
147
+ comments: pr.comments.totalCount + pr.reviewThreads.totalCount,
148
+ })
149
+ for (const issue of pr.closingIssuesReferences.nodes)
150
+ items.set(issue.number, ghItem(issue))
151
+ }
152
+ branches.forEach((branch, i) => {
153
+ const pr = (repo[`b${i}`] as { nodes: GhPr[] } | null)?.nodes[0]
154
+ if (pr) {
155
+ addPr(pr)
156
+ return
157
+ }
158
+ for (const n of refs.get(branch) ?? []) {
159
+ const issue = repo[`i${n}`] as GhIssue | null
160
+ if (issue?.number) items.set(issue.number, ghItem(issue))
161
+ }
162
+ })
163
+ for (const n of prNumbers) {
164
+ const pr = repo[`p${n}`] as GhPr | null
165
+ if (pr) addPr(pr)
166
+ }
167
+ return {
168
+ prs: [...prs.values()],
169
+ items: [...items.values()],
170
+ fetchedAt: Date.now(),
171
+ }
172
+ }
173
+
174
+ async function az(run: Run, args: string[]): Promise<unknown> {
175
+ const out = await run(["az", ...args, "--only-show-errors", "-o", "json"])
176
+ if (out.exitCode !== 0) return null
177
+ try {
178
+ return JSON.parse(out.stdout)
179
+ } catch {
180
+ return null
181
+ }
182
+ }
183
+
184
+ type AzPr = {
185
+ pullRequestId: number
186
+ title: string
187
+ status: string
188
+ isDraft: boolean
189
+ url: string
190
+ sourceRefName: string
191
+ reviewers?: { vote: number }[]
192
+ repository: { id: string; project: { id: string } }
193
+ }
194
+ type AzWorkItem = { id: number; url: string; fields: Record<string, string> }
195
+
196
+ const AZ_STATE: Record<string, string> = {
197
+ active: "open",
198
+ completed: "merged",
199
+ abandoned: "closed",
200
+ }
201
+
202
+ const azItem = (w: AzWorkItem): Item => ({
203
+ id: w.id,
204
+ url: w.url.replace("/_apis/wit/workItems/", "/_workitems/edit/"),
205
+ title: w.fields["System.Title"] ?? "",
206
+ state: (w.fields["System.State"] ?? "").toLowerCase(),
207
+ })
208
+
209
+ // One az call per lookup, run in parallel: each branch's newest PR, each linked
210
+ // PR, then each PR's work items, blocking policies (its checks) and threads.
211
+ export async function fetchAzure(
212
+ run: Run,
213
+ branches: readonly string[],
214
+ prNumbers: readonly number[],
215
+ ): Promise<Work> {
216
+ const refs = await refsByBranch(run, "azure", branches)
217
+ const byBranch = await Promise.all(
218
+ branches.map((b) =>
219
+ az(run, [
220
+ "repos",
221
+ "pr",
222
+ "list",
223
+ "--source-branch",
224
+ b,
225
+ "--status",
226
+ "all",
227
+ "--top",
228
+ "1",
229
+ ]),
230
+ ),
231
+ )
232
+ const byNumber = await Promise.all(
233
+ prNumbers.map((n) => az(run, ["repos", "pr", "show", "--id", String(n)])),
234
+ )
235
+ if (
236
+ [...byBranch, ...byNumber].length > 0 &&
237
+ [...byBranch, ...byNumber].every((r) => r === null)
238
+ ) {
239
+ return {
240
+ prs: [],
241
+ items: [],
242
+ fetchedAt: Date.now(),
243
+ error: "az: every lookup failed (logged in?)",
244
+ }
245
+ }
246
+
247
+ const raw = new Map<number, AzPr>()
248
+ const refsWithoutPr: number[] = []
249
+ byBranch.forEach((list, i) => {
250
+ const pr = (list as AzPr[] | null)?.[0]
251
+ if (pr) raw.set(pr.pullRequestId, pr)
252
+ else refsWithoutPr.push(...(refs.get(branches[i] ?? "") ?? []))
253
+ })
254
+ for (const pr of byNumber as (AzPr | null)[])
255
+ if (pr) raw.set(pr.pullRequestId, pr)
256
+
257
+ const items = new Map<number, Item>()
258
+ const prs = await Promise.all(
259
+ [...raw.values()].map(async (pr): Promise<Pr> => {
260
+ const id = String(pr.pullRequestId)
261
+ const [workItems, policies, threads] = await Promise.all([
262
+ az(run, ["repos", "pr", "work-item", "list", "--id", id]),
263
+ az(run, ["repos", "pr", "policy", "list", "--id", id]),
264
+ az(run, [
265
+ "devops",
266
+ "invoke",
267
+ "--area",
268
+ "git",
269
+ "--resource",
270
+ "pullRequestThreads",
271
+ "--api-version",
272
+ "7.1",
273
+ "--route-parameters",
274
+ `project=${pr.repository.project.id}`,
275
+ `repositoryId=${pr.repository.id}`,
276
+ `pullRequestId=${id}`,
277
+ ]),
278
+ ])
279
+ for (const w of (workItems as AzWorkItem[] | null) ?? [])
280
+ items.set(w.id, azItem(w))
281
+ const blocking = (
282
+ (policies as
283
+ | { status: string; configuration: { isBlocking: boolean } }[]
284
+ | null) ?? []
285
+ )
286
+ .filter((p) => p.configuration.isBlocking)
287
+ .map((p) => p.status)
288
+ const comments = (
289
+ (
290
+ threads as {
291
+ value?: {
292
+ isDeleted: boolean
293
+ comments: { commentType: string }[]
294
+ }[]
295
+ } | null
296
+ )?.value ?? []
297
+ ).filter(
298
+ (t) => !t.isDeleted && t.comments[0]?.commentType === "text",
299
+ ).length
300
+ return {
301
+ number: pr.pullRequestId,
302
+ url: pr.url
303
+ .replace("/_apis/git/repositories/", "/_git/")
304
+ .replace("/pullRequests/", "/pullrequest/"),
305
+ title: pr.title,
306
+ branch: pr.sourceRefName.replace(/^refs\/heads\//, ""),
307
+ state:
308
+ pr.isDraft && pr.status === "active"
309
+ ? "draft"
310
+ : (AZ_STATE[pr.status] ?? pr.status),
311
+ review: azureReview((pr.reviewers ?? []).map((r) => r.vote)),
312
+ checks: azureChecks(blocking),
313
+ comments,
314
+ }
315
+ }),
316
+ )
317
+
318
+ const missing = [...new Set(refsWithoutPr)].filter((n) => !items.has(n))
319
+ if (missing.length > 0) {
320
+ const wiql = `SELECT [System.Id], [System.Title], [System.State] FROM WorkItems WHERE [System.Id] IN (${missing.join(", ")})`
321
+ for (const w of ((await az(run, ["boards", "query", "--wiql", wiql])) as
322
+ | AzWorkItem[]
323
+ | null) ?? [])
324
+ items.set(w.id, azItem(w))
325
+ }
326
+ return { prs, items: [...items.values()], fetchedAt: Date.now() }
327
+ }
@@ -0,0 +1 @@
1
+ { "modules": ["./register.tsx"] }