SLOPSHOPPER

acceptance-progress

A mod that shows the progress of the issue you are working on: when the agent reads or edits a GitHub issue with gh, it reads the issue's checklists (- [ ] / -…

newbandguardcommandpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · acceptance-progress
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /acceptance ⎿ acceptance-progress: No issue read yet. Pick one with /acceptance <number> ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

English | 日本語

acceptance-progress

A Claude Code mod that shows, above the prompt, the progress of the acceptance criteria and checklists in the GitHub issue you are working on. You can see how many criteria are done and which remain without opening the issue page.

Install

claude plugin marketplace add insession-space/claude-ship
claude plugin install acceptance-progress@claude-ship

Restart Claude Code and it loads. The mod is the hooks module named under modules in hooks/hooks.json (hooks/acceptance.tsx). A Claude Code without mods support does not load that module, so nothing happens there. The mod reads issue bodies with gh, so run gh auth login first.

What it shows

When the agent touches an issue that has a checklist with gh, a band appears above the prompt.

╭──────────────────────────────────────────────────────────────────────────╮
│ #33 Show progress on screen     Acceptance criteria ████░░░░ 2/6 │
│ ○ claude plugin test passes                                              │
│ ○ listed in the README                                                   │
│ ○ existing tests stay green                                              │
│ 1 more                                                                   │
│ Open questions 0/2                                                       │
╰──────────────────────────────────────────────────────────────────────────╯
  • The first row is the issue number and title, then the progress bar and count of the acceptance criteria. A long title is cut at the end
  • Then up to three acceptance criteria that are not done yet. The rest are folded into "N more"
  • When everything is done, "All done" replaces the list
  • Checklists under other headings (## Open questions and the like) get one last row with each heading and its count
  • When other mods also draw a band above the prompt (ship-session's progress band, agent-cast), the bands stack

Which checklist counts as the acceptance criteria

A checklist under a heading Acceptance criteria / Definition of done / 受け入れ条件 / 受入条件 / 完了条件 counts as the acceptance criteria, and so do the checklists under its subheadings (### Functional and the like). When no heading matches, every checklist in the body counts. Items are lines starting with - [ ] / * [ ] / 1. [ ], ticked with [x] or [X]; lines inside code fences and HTML comments are not counted.

When it reads the issue again

When the main agent's command succeeds, the mod reads that issue's body back from GitHub with gh issue view --json:

  • gh issue view <N> / gh issue edit <N> / gh issue comment <N> (-R owner/repo and issue URLs are read too)
  • gh issue create (the issue at the URL it prints)

It shows the last issue touched. ship-session's issue-loop ticks each acceptance criterion in the body as soon as it verifies it, so the count moves forward each time.

It neither reads again nor switches the band for:

  • any gh command a subagent runs (so the band does not jump whenever a delegate reads a related issue). Boxes a subagent ticks show up with /acceptance
  • gh issue view with no number (the form that infers it from the branch)
  • a command with no -R and no URL that runs cd or sets GH_REPO before gh (it is unknown which repository the number belongs to)
  • gh on the second line or later (such as a heredoc's text), and a -R that is not owner/repo

It does not poll. A box someone ticks on GitHub directly shows up the next time the issue is touched, or with /acceptance.

Command

CommandWhat it does
/acceptanceRead the shown issue again
/acceptance 42 / /acceptance owner/repo#42 / /acceptance <issue URL>Show that issue
/acceptance offHide the band
/acceptance onBring the band back

When something fails

  • gh is missing, not logged in, or the network fails: the previous band stays, marked "refresh failed". The same goes for a failed switch to another issue, so the band shows it is no longer current
  • The issue does not exist or you cannot see it: the band goes away
  • The issue has no checklist: nothing is shown

The body's text is only displayed; it is never read as a command or a setting.

Language

Taken from Claude Code's language setting, then the language of your latest message, then English.

Not covered

  • Tickets other than GitHub issues, such as Notion pages
  • Checklists in PR bodies
  • Showing several issues at once

Tests

claude plugin validate plugins/acceptance-progress
claude plugin test plugins/acceptance-progress
Source 3 files
hooks/acceptance.tsx 184 lines
1// 作業中の Issue の受け入れ条件とチェックリストの進捗を、入力欄の上のバンドに出す mod。
2// エージェントが gh で触った Issue の本文を GitHub から読み直し、済んだ数と残りの項目を描く。
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register } from 'claude-code'
5
6import type { Lang, TrackedIssue } from '../types'
7import type { IssueRef } from './checklist'
8import {
9  LABELS,
10  bar,
11  countItems,
12  hasJapanese,
13  isNotFound,
14  issueFromJson,
15  langFromSetting,
16  mainGroups,
17  parseCommandArg,
18  refFromCommand,
19  refFromUrl,
20  viewArgv,
21} from './checklist'
22
23const issue = atom({ plugin: 'acceptance-progress', key: 'issue' } as const, null as TrackedIssue | null)
24const isHidden = atom({ plugin: 'acceptance-progress', key: 'isHidden' } as const, false)
25const lang = atom({ plugin: 'acceptance-progress', key: 'lang' } as const, null as Lang | null)
26
27/** バンドに並べる未完了の項目の上限。残りは「ほか N 件」にまとめる */
28const MAX_OPEN_ROWS = 3
29
30// 最後に始めた取得の番号。再読み込みで 0 に戻るが、走っていた取得もそこで捨てられる
31let latestFetch = 0
32
33type Fetched = { kind: 'ok'; issue: TrackedIssue } | { kind: 'not-found' } | { kind: 'failed'; reason: string }
34
35/** gh で本文を読み直す。gh が無い・未認証・ネットワーク失敗は failed、Issue が無いのは not-found。 */
36const fetchIssue = async ($: EngineInterface, ref: IssueRef): Promise<Fetched> => {
37  try {
38    const { exitCode, stdout, stderr } = await $.process.run(viewArgv(ref), { timeoutMs: 15_000 })
39    if (exitCode !== 0) return isNotFound(stderr) ? { kind: 'not-found' } : { kind: 'failed', reason: stderr.trim() }
40    const parsed = issueFromJson(stdout)
41    return parsed === null ? { kind: 'failed', reason: 'unreadable output' } : { kind: 'ok', issue: parsed }
42  } catch (err) {
43    return { kind: 'failed', reason: String(err) }
44  }
45}
46
47/**
48 * Issue を読み直して表示を差し替える。Issue が無い・チェックリストが無いときは消し、
49 * 読めなかったときは前回の表示を残して失敗の印を付ける。別の Issue へ切り替えようとして
50 * 失敗したときも印を付け、残っている表示が最新ではないことを示す。
51 */
52const refresh = async ($: EngineInterface, ref: IssueRef): Promise<Fetched> => {
53  // 取得は並んで走ることがある(並列の Bash 呼び出し、コマンドとエージェントの重なり)。
54  // 後から始めた取得が先に終わったとき、先に始めた古い取得の結果で上書きしない
55  const mine = ++latestFetch
56  const got = await fetchIssue($, ref)
57  if (mine !== latestFetch) return got
58  await update($, issue, cur => {
59    if (got.kind === 'ok') return got.issue.groups.length === 0 ? null : got.issue
60    if (got.kind === 'not-found') return null
61    return cur === null ? cur : { ...cur, error: got.reason }
62  })
63
64  return got
65}
66
67export const register: Register = on => {
68  // settings の `language` が決まっていればそれを使い、無ければプロンプトの文字で決める
69  let isLangFromSettings = false
70
71  on('session.start', async ($, e, next) => {
72    const fromSettings = langFromSetting((await $.settings.read()).language)
73    isLangFromSettings = fromSettings !== null
74    if (fromSettings !== null) await update($, lang, () => fromSettings)
75    await $.command.register({
76      name: 'acceptance',
77      description: 'Show the acceptance criteria progress of a GitHub issue above the prompt',
78      argumentHint: '[number | owner/repo#N | URL | off | on]',
79    })
80
81    return next(e)
82  })
83
84  on('prompt.submit', async ($, e, next) => {
85    // ユーザーが打った文だけを見る(タスク通知などエンジン由来の文は英語で来る)
86    const isUserText = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
87    if (isUserText && !isLangFromSettings) await update($, lang, () => (hasJapanese(e.text) ? 'ja' : 'en'))
88
89    return next(e)
90  })
91
92  on('command.run', { command: 'acceptance' }, async ($, e) => {
93    const l = LABELS[(await read($, lang)) ?? 'en']
94    const arg = parseCommandArg(e.args)
95    if (arg.kind === 'usage') return { text: l.usage }
96    if (arg.kind === 'off' || arg.kind === 'on') {
97      await update($, isHidden, () => arg.kind === 'off')
98      return { text: arg.kind === 'off' ? l.hidden : l.shown }
99    }
100
101    const cur = await read($, issue)
102    const ref = arg.kind === 'show' ? arg.ref : cur === null ? null : { repo: cur.repo, number: cur.number }
103    if (ref === null) return { text: l.none }
104    await update($, isHidden, () => false)
105    const got = await refresh($, ref)
106    if (got.kind === 'not-found') return { text: l.notFound(ref.number) }
107    if (got.kind === 'failed') return { text: l.fetchFailed(ref.number) }
108    if (got.issue.groups.length === 0) return { text: l.noChecklist(ref.number) }
109    const { done, total } = countItems(mainGroups(got.issue.groups))
110    return { text: l.showing(ref.number, done, total) }
111  })
112
113  // メインエージェントが gh で触った Issue を読み直す。サブエージェントの呼び出しでは切り替えない
114  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
115    const ran = await next(e)
116    if (ran.deny !== undefined || ran.isError === true || e.agentId !== undefined) return ran
117
118    const created = /\bgh\s+issue\s+create\b/.test(e.command) ? refFromUrl(ran.text ?? '') : null
119    const ref = refFromCommand(e.command) ?? created
120    if (ref === null) return ran
121    // リポジトリを書いていないコマンドが表示中と同じ番号を指すなら、表示中のリポジトリとみなす。
122    // エージェントが別のディレクトリへ cd していても、セッションのカレントで読み違えないため
123    const cur = await read($, issue)
124    await refresh($, ref.repo === null && cur?.number === ref.number ? { ...ref, repo: cur.repo } : ref)
125
126    return ran
127  })
128
129  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
130    const cur = await read($, issue)
131    if (e.props.hasSurvey || cur === null || (await read($, isHidden))) return next(e)
132
133    // 他のプラグイン(agent-cast や ship-session のバンド)が描くものの下に並べる
134    const below = await next(e)
135    const { Box, Text } = $.ui.resolve(e)
136    const l = LABELS[(await read($, lang)) ?? 'en']
137    const main = mainGroups(cur.groups)
138    const others = cur.groups.filter(g => !main.includes(g))
139    const { done, total } = countItems(main)
140    const open = main.flatMap(g => g.items).filter(i => !i.isDone)
141
142    return (
143      <Box flexDirection="column">
144        {below}
145        <Box flexDirection="column" borderStyle="round" borderColor="green" paddingX={1}>
146          <Box justifyContent="space-between" columnGap={2}>
147            <Text bold wrap="truncate-end">
148              #{cur.number} {cur.title}
149            </Text>
150            <Box flexShrink={0}>
151              <Text>
152                <Text dimColor>{main[0]?.isAcceptance === true ? l.acceptance : l.other} </Text>
153                <Text color="green">{bar(done, total, 12)}</Text> <Text bold>{done}/{total}</Text>
154              </Text>
155            </Box>
156          </Box>
157          {open.length === 0 ? (
158            <Text color="green">✔ {l.allDone}</Text>
159          ) : (
160            open.slice(0, MAX_OPEN_ROWS).map(i => (
161              <Text wrap="truncate-end">
162                <Text dimColor>○ </Text>
163                {i.text}
164              </Text>
165            ))
166          )}
167          {open.length > MAX_OPEN_ROWS && <Text dimColor>{l.more(open.length - MAX_OPEN_ROWS)}</Text>}
168          {others.length > 0 && (
169            <Text dimColor wrap="truncate-end">
170              {others
171                .map(g => {
172                  const c = countItems([g])
173                  return `${g.heading ?? l.noHeading} ${c.done}/${c.total}`
174                })
175                .join('  ·  ')}
176            </Text>
177          )}
178          {cur.error !== null && <Text color="yellow">⚠ {l.failed}</Text>}
179        </Box>
180      </Box>
181    )
182  })
183}
184
hooks/checklist.ts 281 lines
1// Issue 本文のチェックリストと、gh コマンドが指す Issue を読み取る純粋関数。
2// 描画(acceptance.tsx)から切り離して、解析だけをテストできるようにしている。
3import type { ChecklistGroup, ChecklistItem, Lang, TrackedIssue } from '../types'
4
5/**
6 * 受け入れ条件の見出し。create-issue のテンプレートの見出しと、その英訳。
7 * 見出し全体で比べる(`Non-acceptance criteria` を拾わない)。後ろの補足のカッコだけは許す
8 */
9const ACCEPTANCE_HEADING =
10  /^(受け入れ条件|受入条件|完了条件|acceptance criteria|definition of done)\s*(\(.*\)|(.*))?\s*:?$/i
11
12/** フェンスの行。記号の並びと、その後ろ(info 文字列)を取る */
13const FENCE = /^\s{0,3}(`{3,}|~{3,})(.*)$/
14const HEADING = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/
15const ITEM = /^\s*(?:[-*+]|\d+[.)])\s+\[([ xX])\]\s+(.*)$/
16
17/** 項目の文からマークダウンの記号を落として1行にする。 */
18export const plainText = (text: string): string =>
19  text
20    .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
21    .replace(/(\*\*|__|~~|`)/g, '')
22    .replace(/\s+/g, ' ')
23    .trim()
24
25/**
26 * 本文のチェックリストを見出しごとにまとめる。コードフェンスの中は数えない。
27 * 受け入れ条件のグループを先頭に、残りは本文の順に並べる。
28 */
29export const parseChecklist = (body: string): ChecklistGroup[] => {
30  const groups: ChecklistGroup[] = []
31  let heading: string | null = null
32  // 受け入れ条件の見出しの深さ。その下の小見出し(`### 機能` など)も受け入れ条件として数える
33  let acceptanceLevel: number | null = null
34  let current: ChecklistItem[] | null = null
35  // 開いているフェンスの記号の並び(CommonMark と同じく、同じ記号で同じ長さ以上の行が閉じる)
36  let fence: string | null = null
37
38  // HTML コメントの中は GitHub に表示されないので数えない。フェンスの中の `<!--` は例示なので数えない対象にしない
39  let isInComment = false
40  for (const raw of body.split(/\r?\n/)) {
41    let line = raw
42    if (isInComment) {
43      const end = line.indexOf('-->')
44      if (end < 0) continue
45      isInComment = false
46      line = line.slice(end + 3)
47    }
48    const f = FENCE.exec(line)
49    if (fence === null && f === null && line.includes('<!--')) {
50      line = line.replace(/<!--[\s\S]*?-->/g, '')
51      const open = line.indexOf('<!--')
52      if (open >= 0) {
53        isInComment = true
54        line = line.slice(0, open)
55      }
56    }
57    if (fence !== null) {
58      const closes = f !== null && f[1]?.[0] === fence[0] && (f[1]?.length ?? 0) >= fence.length && f[2]?.trim() === ''
59      if (closes) fence = null
60      continue
61    }
62    // バッククォートのフェンスは info 文字列にバッククォートを含まない(行頭のインラインコードと区別する)
63    if (f !== null && !(f[1]?.startsWith('`') && f[2]?.includes('`'))) {
64      fence = f[1] ?? null
65      continue
66    }
67
68    const h = HEADING.exec(line)
69    if (h !== null) {
70      const level = h[1]?.length ?? 1
71      heading = plainText(h[2] ?? '')
72      if (ACCEPTANCE_HEADING.test(heading)) acceptanceLevel = level
73      else if (acceptanceLevel !== null && level <= acceptanceLevel) acceptanceLevel = null
74      current = null
75      continue
76    }
77    const item = ITEM.exec(line)
78    if (item === null) continue
79    if (current === null) {
80      current = []
81      groups.push({ heading, isAcceptance: acceptanceLevel !== null, items: current })
82    }
83    current.push({ text: plainText(item[2] ?? ''), isDone: item[1] !== ' ' })
84  }
85
86  return [...groups.filter(g => g.isAcceptance), ...groups.filter(g => !g.isAcceptance)]
87}
88
89/**
90 * 進捗として大きく出すグループ。受け入れ条件の見出しがあればそれだけ、
91 * 無ければ全部のチェックリスト。
92 */
93export const mainGroups = (groups: readonly ChecklistGroup[]): ChecklistGroup[] => {
94  const acceptance = groups.filter(g => g.isAcceptance)
95  return acceptance.length > 0 ? acceptance : [...groups]
96}
97
98/** グループの項目のうち、済んだ数と全体の数。 */
99export const countItems = (groups: readonly ChecklistGroup[]): { done: number; total: number } => {
100  const items = groups.flatMap(g => g.items)
101  return { done: items.filter(i => i.isDone).length, total: items.length }
102}
103
104export type IssueRef = { repo: string | null; number: number }
105
106const ISSUE_URL = /https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/issues\/(\d+)/
107const REPO = /^[\w.-]+\/[\w.-]+$/
108
109/** Issue の URL からリポジトリと番号を取る。 */
110export const refFromUrl = (text: string): IssueRef | null => {
111  const m = ISSUE_URL.exec(text)
112  return m === null ? null : { repo: m[1] ?? null, number: Number(m[2]) }
113}
114
115/** 値を取るフラグ(`-R owner/repo` の `-R` など)。この直後の語は Issue 番号として読まない */
116const VALUE_FLAGS = new Set([
117  '-R', '--repo', '--json', '-q', '--jq', '-t', '--template', '--title', '-b', '--body', '-F', '--body-file',
118  '--add-label', '--remove-label', '--add-assignee', '--remove-assignee', '--add-project', '--remove-project',
119  '-m', '--milestone', '-e', '--editor-cmd',
120])
121
122/**
123 * `gh issue view|edit|comment <N>` が指す Issue。番号を書いていないコマンド
124 * (ブランチから推定する形など)と、別のコマンドは null。
125 */
126export const refFromCommand = (command: string): IssueRef | null => {
127  // 1行目だけを読む。2行目以降は heredoc の本文やエコーした文のことがあり、
128  // そこに書かれた `gh issue view 1 -R ...` を実行されたコマンドとして読まないため
129  const firstLine = command.split(/\r?\n/, 1)[0] ?? ''
130  // 引用符の中(タイトルや本文)の数字を番号と取り違えないよう、先に1語の印に置き換える。
131  // 消してしまうと `--title "…" 7` の値が無くなり、番号の 7 をフラグの値として読み飛ばす。
132  // `--repo "acme/app"` のように空白を含まない値は、引用符だけを外して残す
133  const bare = firstLine.replace(/"(?:\\.|[^"\\])*"|'[^']*'/g, q => {
134    const inner = q.slice(1, -1)
135    return /^[\w.\/#:-]+$/.test(inner) ? inner : ' _ '
136  })
137  // コマンドの位置(行頭か、`&&` `;` `|` の直後)にある gh だけを読む
138  const m = /(?:^|&&|\|\||[;|])\s*gh\s+issue\s+(?:view|edit|comment)\b(.*)/.exec(bare)
139  if (m === null) return null
140  // gh より前で `cd` したり GH_REPO を渡したりしたコマンドは、どのリポジトリを指すか分からない。
141  // セッションのカレントで読むと別のリポジトリの同じ番号を出すので、-R か URL が無ければ読まない
142  const before = bare.slice(0, m.index + m[0].search(/gh\s/))
143  const isElsewhere = /(?:^|[\s;&|])(?:cd|pushd)\s|\bGH_REPO=/.test(before)
144  const tokens = (m[1] ?? '').split(/\s*(?:\|\||&&|[|;&])\s*/)[0]?.split(/\s+/).filter(Boolean) ?? []
145
146  let repo: string | null = null
147  let ref: IssueRef | null = null
148  for (let i = 0; i < tokens.length; i++) {
149    const t = tokens[i] ?? ''
150    const [flag, inline] = t.split('=', 2) as [string, string | undefined]
151    if (flag === '-R' || flag === '--repo') {
152      repo = inline ?? tokens[i + 1] ?? null
153      if (inline === undefined) i++
154      continue
155    }
156    if (VALUE_FLAGS.has(flag)) {
157      if (inline === undefined) i++
158      continue
159    }
160    if (ref !== null || t.startsWith('-')) continue
161    const n = /^#?(\d+)$/.exec(t)?.[1]
162    ref = n !== undefined ? { repo: null, number: Number(n) } : refFromUrl(t)
163  }
164  if (ref === null) return null
165  // `-R` は `owner/repo` の形だけを受け付ける。gh は `HOST/OWNER/REPO` も読むので、
166  // 形を確かめないと任意のホストへ gh が問い合わせる
167  const repoOk = repo === null || REPO.test(repo)
168  if (ref.repo === null && !repoOk) return null
169  const resolved = ref.repo ?? repo
170  if (resolved === null && isElsewhere) return null
171
172  return { repo: resolved, number: ref.number }
173}
174
175/** 本文を読み直す gh コマンドの引数。 */
176export const viewArgv = (ref: IssueRef): string[] => [
177  'gh',
178  'issue',
179  'view',
180  String(ref.number),
181  ...(ref.repo === null ? [] : ['-R', ref.repo]),
182  '--json',
183  'number,title,body,url',
184]
185
186/** Issue が無い・見られない失敗か(ネットワークや認証の失敗と分ける)。 */
187export const isNotFound = (stderr: string): boolean =>
188  // `command not found`(gh が無い)などの汎用の not found は含めない。それは failed として前回の表示を残す
189  /could not resolve to an? (issue|pullrequest|repository)|http 404|is a pull request/i.test(stderr)
190
191/**
192 * `gh issue view --json number,title,body,url` の出力を表示用に組み立てる。
193 * 読めない出力は null。
194 */
195export const issueFromJson = (stdout: string): TrackedIssue | null => {
196  let data: unknown
197  try {
198    data = JSON.parse(stdout)
199  } catch {
200    return null
201  }
202  if (data === null || typeof data !== 'object') return null
203  const { number, title, body, url } = data as Record<string, unknown>
204  if (typeof number !== 'number' || typeof url !== 'string') return null
205
206  return {
207    repo: refFromUrl(url)?.repo ?? null,
208    number,
209    title: typeof title === 'string' ? title : '',
210    url,
211    groups: parseChecklist(typeof body === 'string' ? body : ''),
212    error: null,
213  }
214}
215
216/** `/acceptance` の引数。 */
217export type CommandArg = { kind: 'refresh' } | { kind: 'off' } | { kind: 'on' } | { kind: 'show'; ref: IssueRef } | { kind: 'usage' }
218
219export const parseCommandArg = (args: string): CommandArg => {
220  const a = args.trim()
221  if (a === '') return { kind: 'refresh' }
222  if (/^(off|hide)$/i.test(a)) return { kind: 'off' }
223  if (/^(on|show)$/i.test(a)) return { kind: 'on' }
224  const n = /^#?(\d+)$/.exec(a)?.[1]
225  if (n !== undefined) return { kind: 'show', ref: { repo: null, number: Number(n) } }
226  const short = /^([\w.-]+\/[\w.-]+)#(\d+)$/.exec(a)
227  if (short !== null) return { kind: 'show', ref: { repo: short[1] ?? null, number: Number(short[2]) } }
228  const url = refFromUrl(a)
229  return url === null ? { kind: 'usage' } : { kind: 'show', ref: url }
230}
231
232/** 文字列に日本語(かな・漢字)が入っているか。 */
233export const hasJapanese = (text: string): boolean => /[぀-ヿ一-鿿]/.test(text)
234
235/** settings の `language` から表示言語を決める。決まらなければ null。 */
236export const langFromSetting = (language: unknown): Lang | null => {
237  if (typeof language !== 'string' || language.trim() === '') return null
238  return /^(ja([-_].*)?|日本語?|japanese)$/i.test(language.trim()) ? 'ja' : 'en'
239}
240
241/** `done / total` の進捗バー。 */
242export const bar = (done: number, total: number, width: number): string => {
243  const filled = total === 0 ? 0 : Math.round((done / total) * width)
244  return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
245}
246
247export const LABELS = {
248  ja: {
249    acceptance: '受け入れ条件',
250    other: 'チェック',
251    noHeading: '(見出しなし)',
252    allDone: 'すべて完了',
253    more: (n: number) => `ほか ${n} 件`,
254    failed: '読み直しに失敗',
255    usage: '使い方: /acceptance [Issue 番号 | owner/repo#N | URL | off | on]',
256    hidden: '受け入れ条件の表示を隠しました。/acceptance on で戻します',
257    shown: '受け入れ条件の表示を戻しました',
258    none: 'まだ Issue を読んでいません。/acceptance <番号> で指定できます',
259    noChecklist: (n: number) => `#${n} にチェックリストがありません`,
260    notFound: (n: number) => `#${n} が見つからないか、見る権限がありません`,
261    fetchFailed: (n: number) => `#${n} の本文を読めませんでした`,
262    showing: (n: number, done: number, total: number) => `#${n} の受け入れ条件 ${done}/${total}`,
263  },
264  en: {
265    acceptance: 'Acceptance criteria',
266    other: 'Checklist',
267    noHeading: '(no heading)',
268    allDone: 'All done',
269    more: (n: number) => `${n} more`,
270    failed: 'refresh failed',
271    usage: 'Usage: /acceptance [issue number | owner/repo#N | URL | off | on]',
272    hidden: 'Hid the acceptance criteria. /acceptance on brings them back',
273    shown: 'Showing the acceptance criteria again',
274    none: 'No issue read yet. Pick one with /acceptance <number>',
275    noChecklist: (n: number) => `#${n} has no checklist`,
276    notFound: (n: number) => `#${n} was not found, or you cannot see it`,
277    fetchFailed: (n: number) => `Could not read #${n}`,
278    showing: (n: number, done: number, total: number) => `#${n} acceptance criteria ${done}/${total}`,
279  },
280} as const
281
types/index.d.ts 44 lines
1/** チェックリストの1項目(`- [ ]` / `- [x]` の1行)。 */
2export type ChecklistItem = {
3  /** マークダウンの記号を落とした項目の文 */
4  text: string
5  isDone: boolean
6}
7
8/** 見出し1つ分のチェックリスト。 */
9export type ChecklistGroup = {
10  /** 直前の `##`〜`####` 見出し。見出しより前の項目は null */
11  heading: string | null
12  /** 受け入れ条件の見出し(`受け入れ条件` / `Acceptance criteria` など)か */
13  isAcceptance: boolean
14  items: ChecklistItem[]
15}
16
17/** 表示中の Issue。GitHub の本文から読んだもの。 */
18export type TrackedIssue = {
19  /** `owner/name`。コマンドにも URL にも無ければ null(セッションのカレントのリポジトリ) */
20  repo: string | null
21  number: number
22  title: string
23  url: string
24  /** 受け入れ条件のグループが先頭に来る */
25  groups: ChecklistGroup[]
26  /** 最後に読み直しに失敗したときの理由。成功したら null に戻す */
27  error: string | null
28}
29
30/** 表示言語 */
31export type Lang = 'ja' | 'en'
32
33declare module 'claude-code' {
34  interface PluginState {
35    'acceptance-progress': {
36      /** 表示中の Issue。チェックリストを持たない Issue と、まだ何も読んでいないときは null */
37      issue: TrackedIssue | null
38      /** `/acceptance off` で隠したか */
39      isHidden: boolean
40      lang: Lang | null
41    }
42  }
43}
44