SLOPSHOPPER

ilerleme-cubugu

Süren geliştirme işinin ilerlemesini (Claude'un görev listesi ya da PLAN.md / STATE.md planı) prompt kutusunun üstünde çubuk olarak gösterir: biten adım…

newbandguardcommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ilerleme-cubugu
› 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 › /ilerleme ⎿ ilerleme-cubugu: Kullanım: /ilerleme gizle | göster | detay ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Claude Code Araçları

Kendi işimde her gün kullandığım Claude Code modları. Kod yazmadan, Claude'a anlatarak yaptırdım; burada herkes için paylaşıyorum.

🎥 Videoda nasıl çalıştıklarını gösterdim: https://youtu.be/-B0hXhOzuUI 🏫 Soru, destek ve yeni araçlar için ücretsiz Skool topluluğu: https://www.skool.com/mustafa-dikmen-ile-yapay-zeka-1691/about

ModNe yapar
otomatik-devirSohbetin bağlamı dolmaya yaklaşınca işi güvenli bir yerde bitirtir, devam notu yazdırır, sohbeti temizleyip işe kaldığı yerden devam ettirir.
ilerleme-cubuguSüren işin ilerlemesini yazı kutusunun üstünde çubuk olarak gösterir: kaç adımın bittiği, yüzde kaç olduğu ve şu an hangi adımda olunduğu.

"Mod", Claude Code'un içine takılan küçük bir eklentidir. Claude Code'un görünüşünü ve davranışını değiştirir.

Kurulum

Gerekli: Claude Code 2.1.287 veya üstü. Terminalde claude --version yazıp bakın; eskiyse claude update.

Yol 1: Claude'a kurdurun (en kolayı)

Claude Code'u açın ve şu metni olduğu gibi yapıştırın. Komut çalıştırma izni isteyecek, onaylayın.

Şu 3 komutu sırayla çalıştır, başka hiçbir şey kurma veya değiştirme: (1) claude plugin marketplace add muctebadikmen/claude-code-araclari (2) claude plugin install otomatik-devir@muctebadikmen --scope user (3) claude plugin install ilerleme-cubugu@muctebadikmen --scope user. Sonunda claude plugin list çalıştır ve 2 modun da "enabled" olduğunu söyle. Hata olursa dur ve bana hatayı anlat.

Yol 2: Terminalden

claude plugin marketplace add muctebadikmen/claude-code-araclari
claude plugin install otomatik-devir@muctebadikmen --scope user
claude plugin install ilerleme-cubugu@muctebadikmen --scope user
claude plugin list

--scope user "tüm projelerimde geçerli olsun" demektir. Listede ikisini de "enabled" görmelisiniz. Sonra Claude Code'da yeni bir sohbet açın ya da /reload-plugins yazın.

İsterseniz yalnız birini kurabilirsiniz; iki mod birbirinden bağımsız çalışır.

Güncelleme

Modları geliştirdikçe buraya yüklüyorum. Yeni sürümü almak için:

claude plugin marketplace update muctebadikmen
claude plugin update otomatik-devir@muctebadikmen
claude plugin update ilerleme-cubugu@muctebadikmen

Sonra Claude Code'u yeniden başlatın.

Nasıl kullanılır

otomatik-devir

Alt satırdaki durum çubuğunda Devir: %X / eşik %60 görürsünüz (ilk yanıttan önce %0 görünür). Doluluk eşiği aşınca mod, işi güvenli bir noktada bitirtir ve devam notu yazdırır (.workflow/HANDOFF.md). Sonra "Yeni sohbete geçeyim mi?" diye sorar: g = Geç, b = Bekle. "Geç" dediğinizde sohbeti temizler (/clear), devam prompt'unu yapıştırır ve iş kaldığı yerden sürer.

KomutNe yapar
/devirDurumu gösterir
/devir esik 70Eşiği %70 yapar (1-99)
/devir otomatikSormadan yeni sohbete geçer
/devir sorGeri sorarak geçmeye döner (varsayılan)
/devir simdiDevri hemen başlatır
/devir gecBekleyen devirde yeni sohbete geçer
/devir kapat / /devir acModu kapatır / açar

Kendi devir becerisi (my-skill-handoff) olanlarda mod onu kullanır. Olmayanlarda kendi hazır devir talimatını gönderir; ekstra bir şey kurmanız gerekmez.

ilerleme-cubugu

İki kaynaktan çubuk çizer:

  1. Claude'un görev listesi. Claude bir işi adımlara bölüp görev listesi tuttuğunda çubuk kendiliğinden çıkar.
  2. Plan dosyası. PLAN.md, .workflow/PLAN.md, .workflow/STATE.md ya da .workflow/plans/<klasör>/PLAN.md. Kutucuklar (- [x], - [ ]), numaralı adımlar ve - id: T1 biçimli planlar okunur. Plan dosyasının çubuğu o dosyalardan bahseden sohbete bağlanır: mesajınızda PLAN.md ya da STATE.md geçtiğinde ya da Claude bu dosyaları okuyup yazdığında çubuk çıkar. Aynı projede açtığınız alakasız bir sohbette eski plan görünmez.

Videodaki gibi başlatmak için örnek mesaj:

.workflow/PLAN.md ve .workflow/STATE.md: benim için bu formatta bir plan yap ve bu planı uygula. <işin tarifi>

Çubuğa tıklarsanız adım listesi açılır: ✓ biten, ● şu anki, ○ bekleyen.

KomutNe yapar
/ilerleme detayAdım listesini açar / kapatır (çubuğa tıklamak da aynı işi yapar)
/ilerleme gizleÇubuğu gizler
/ilerleme gosterÇubuğu geri getirir

İki mod birlikte çalışır: devirden sonra yeni sohbete yapıştırılan devam prompt'u plan ya da devir dosyalarından (PLAN.md, STATE.md, HANDOFF.md) bahsettiğinde çubuk yeni sohbette de geri gelir. Bahsetmiyorsa Claude o dosyaları okuduğu anda gelir.

Kaldırma

claude plugin uninstall otomatik-devir@muctebadikmen --scope user
claude plugin uninstall ilerleme-cubugu@muctebadikmen --scope user
claude plugin marketplace remove muctebadikmen

Güvenlik

Modlar Claude Code'un içinde sizin yetkilerinizle çalışır, bu yüzden kurmadan önce bakmanız doğru. Kodun tamamı bu depoda; modlar/<mod>/hooks/ klasörlerinde okuyabilirsiniz. Depoyu indirip bir modu kendiniz de denetleyebilirsiniz:

claude plugin validate modlar/otomatik-devir
claude plugin validate modlar/ilerleme-cubugu

Çıktı, modun hangi işlemleri yaptığını listeler. İki modun listesinde de ağ çağrısı yoktur. ilerleme-cubugu yalnızca proje klasöründeki plan dosyalarını okur, hiçbir dosyaya yazmaz. otomatik-devir devam notunu Claude'a yazdırır, kendisi dosya yazmaz; devir anında sohbeti temizleyip devam prompt'unu gönderir.

Bilinen sınırlar

  • otomatik-devir: Alt komutlar Türkçe harfsiz yazılır: esik, simdi (eşik çalışmaz). Devir yanıtı bir kod bloğuyla bitmezse "devam prompt'u bulunamadı" der. Sohbet temizlenemezse devam metni panoya kopyalanır ve ekranda "/clear yazıp yeni sohbete yapıştır" yazar.
  • ilerleme-cubugu: Plan dosyasını editörde elle değiştirirseniz çubuğun güncellenmesi birkaç saniye sürebilir. Plan çubuğu yalnız o dosyalardan bahseden sohbette görünür. İş bitince çubuk kısa bir süre "✓ Tamamlandı" gösterip kaybolur.
  • Yazı kutusunun üstündeki şerit paylaşılır: iki mod (ve varsa başka modlar) aynı şeride çizer. Başka modun düğmeleri (ör. "Geç / Bekle") her zaman üstte kalır; adım listesi açıkken bile onları ekrandan itmez.

Geliştirenler için

Her modun testleri modlar/<mod>/tests/ içinde. Çalıştırmak için mod klasöründe:

claude plugin test .

Lisans

MIT. Kullanın, değiştirin, paylaşın. Sorun görürseniz bir issue açın ya da Skool topluluğunda yazın.

Source 3 files
hooks/register.tsx 407 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderInput, Timer } from 'claude-code'
3
4import type { IlerlemeCubuguAdim as Adim, IlerlemeCubuguDurum as Durum } from '../types'
5import { genericSteps, mySkillSteps, parseMsPlan, parseState, planDirsFromState } from './progress'
6import type { MsPlan } from './progress'
7
8const tasks = atom({ plugin: 'ilerleme-cubugu', key: 'tasks' } as const, [])
9const plan = atom({ plugin: 'ilerleme-cubugu', key: 'plan' } as const, [])
10const job = atom({ plugin: 'ilerleme-cubugu', key: 'job' } as const, { startedAt: null, doneAt: null })
11const isHidden = atom({ plugin: 'ilerleme-cubugu', key: 'isHidden' } as const, false)
12const isExpanded = atom({ plugin: 'ilerleme-cubugu', key: 'isExpanded' } as const, false)
13const engagedSid = atom({ plugin: 'ilerleme-cubugu', key: 'engagedSid' } as const, null)
14
15const HIDE_MS = 4000
16const TICK_MS = 5000
17const MAX_CELLS = 20
18const USAGE = 'Kullanım: /ilerleme gizle | göster | detay'
19// a text or tool argument that names the my-skill workflow files
20const WORKFLOW = /\b(?:PLAN|STATE)\.md\b|\bHANDOFF\b|\.workflow\/plans\//
21const ENGAGING_SKILLS = new Set(['my-skill-execute', 'my-skill-handoff', 'my-skill-masterplan'])
22
23type $ = EngineInterface
24type Args = Record<string, unknown>
25
26const text = (value: unknown) => (typeof value === 'string' ? value.trim() : '')
27
28const toStatus = (value: unknown): Durum | 'deleted' | undefined =>
29  value === 'pending' || value === 'in_progress' || value === 'completed' || value === 'deleted'
30    ? value
31    : undefined
32
33const isDone = (step: Adim) => step.status === 'completed'
34
35const current = (steps: Adim[]) =>
36  steps.find(step => step.status === 'in_progress') ?? steps.find(step => !isDone(step))
37
38const fromTodos = (todos: unknown): Adim[] =>
39  (Array.isArray(todos) ? todos : []).flatMap((todo: Args, i) => {
40    const content = text(todo?.content)
41    const status = toStatus(todo?.status)
42    if (!content || !status || status === 'deleted') return []
43    return [{ id: `todo-${i}`, content, activeForm: text(todo.activeForm), status }]
44  })
45
46const createdId = (ran: { result?: unknown; text?: string }) => {
47  const result = (ran.result ?? {}) as Args
48  const task = (result.task ?? {}) as Args
49  const id = task.id ?? result.id ?? result.taskId
50  if (typeof id === 'string' || typeof id === 'number') return String(id)
51  return /#(\w+)/.exec(ran.text ?? '')?.[1]
52}
53
54const upsert = (list: Adim[], step: Adim) => {
55  const startsOver = list.length > 0 && list.every(isDone)
56  const base = startsOver ? [] : list
57  const twin = base.findIndex(
58    one => one.id === step.id || (one.id.startsWith('tu:') && one.content === step.content),
59  )
60  if (twin === -1) return [...base, step]
61  return base.map((one, i) => (i === twin ? { ...one, id: step.id } : one))
62}
63
64const patch = (list: Adim[], id: string, args: Args) => {
65  const status = toStatus(args.status)
66  if (status === 'deleted') return list.filter(one => one.id !== id)
67  return list.map(one =>
68    one.id !== id
69      ? one
70      : {
71          ...one,
72          status: status ?? one.status,
73          content: text(args.subject) || one.content,
74          activeForm: text(args.activeForm) || one.activeForm,
75        },
76  )
77}
78
79const bar = (done: number, total: number) => {
80  const cells = Math.min(total, MAX_CELLS)
81  const filled = Math.round((done / total) * cells)
82  return '▓'.repeat(filled) + '░'.repeat(cells - filled)
83}
84
85const clip = (value: string, room: number) =>
86  value.length <= room ? value : `${value.slice(0, Math.max(1, room - 1))}…`
87
88type Found = { path: string; mtimeMs: number }
89
90const STATE_PATH = '.workflow/STATE.md'
91const planPath = (dir: string) => `.workflow/plans/${dir}/PLAN.md`
92
93const statFile = ($: $, path: string): Promise<Found | undefined> =>
94  $.fs.stat(path).then(
95    stat => (stat.kind === 'file' ? { path, mtimeMs: stat.mtimeMs } : undefined),
96    () => undefined,
97  )
98
99const newest = (list: (Found | undefined)[]) =>
100  list.filter(one => one !== undefined).sort((a, b) => b.mtimeMs - a.mtimeMs)
101
102let seen = ''
103// one interval per process: a repeated session.start replaces it, never stacks a second one
104let ticker: Timer | undefined
105
106// File I/O only; the parsing is in ./progress (pure, unit-tested). Order (spec §2.1):
107// a my-skill plan (the one STATE.md names, else the newest .workflow/plans/*/PLAN.md)
108// with its status from STATE.md; then the generic ladder on PLAN.md,
109// .workflow/PLAN.md, STATE.md and the newest plan, newest first.
110async function refreshPlan($: $) {
111  const entries = await $.fs.list('.workflow/plans').catch(() => [])
112  const dirs = entries.filter(entry => entry.kind === 'dir').map(entry => entry.name)
113  const plans = await Promise.all(dirs.map(dir => statFile($, planPath(dir))))
114  const fixed = await Promise.all(['PLAN.md', '.workflow/PLAN.md', STATE_PATH].map(path => statFile($, path)))
115
116  // re-read only when any file consulted changed (or appeared, or went away)
117  const stamp = newest([...plans, ...fixed])
118    .map(one => `${one.path}:${one.mtimeMs}`)
119    .sort()
120    .join('|')
121  if (stamp === seen) return
122
123  const texts = new Map<string, Promise<string>>()
124  const load = (path: string) => {
125    if (!texts.has(path)) texts.set(path, $.fs.read(path).catch(() => ''))
126    return texts.get(path) as Promise<string>
127  }
128
129  const state = fixed[2]
130  const stateText = state ? await load(STATE_PATH) : ''
131  const [active] = newest(plans)
132  const named = state ? planDirsFromState(stateText, dirs.filter((_, i) => plans[i] !== undefined)) : []
133
134  let mine: { plan: MsPlan; found: Found; isNamed: boolean } | undefined
135  for (const dir of named) {
136    const found = plans[dirs.indexOf(dir)]
137    const parsed = found && parseMsPlan(await load(found.path))
138    if (found && parsed) {
139      mine = { plan: parsed, found, isNamed: true }
140      break
141    }
142  }
143  if (!mine && active) {
144    const parsed = parseMsPlan(await load(active.path))
145    if (parsed) mine = { plan: parsed, found: active, isNamed: false }
146  }
147
148  let steps: Adim[] = []
149  if (mine) {
150    // an unnamed plan newer than STATE.md is not what STATE.md's ids are about
151    const credit = state !== undefined && (mine.isNamed || state.mtimeMs >= mine.found.mtimeMs)
152    steps = mySkillSteps(mine.plan, credit ? parseState(stateText, new Set(mine.plan.ids)) : undefined)
153  }
154  // an all-done my-skill plan yields to anything else open; kept, it lets reconcile
155  // flash "✓ Tamamlandı" for a job it saw run
156  if (steps.every(one => one.status === 'completed')) {
157    const skip = new Set([mine?.found.path, mine?.isNamed ? STATE_PATH : undefined])
158    for (const { path } of newest([...fixed, active])) {
159      if (skip.has(path)) continue
160      const found = genericSteps(await load(path), path === STATE_PATH)
161      if (found.length > 0 && (steps.length === 0 || found.some(one => one.status !== 'completed'))) {
162        steps = found
163        break
164      }
165    }
166  }
167
168  seen = stamp
169  await update($, plan, () => steps)
170}
171
172const sessionId = ($: $) => $.session.id().catch(() => '')
173
174// The plan-file bar belongs to the conversation that engaged it (a prompt naming the
175// workflow files, an engaging skill, a tool call on them); a task list always does.
176async function isEngaged($: $) {
177  const sid = await read($, engagedSid)
178  return sid !== null && sid === (await sessionId($))
179}
180
181async function engage($: $) {
182  const sid = await sessionId($)
183  await update($, engagedSid, () => sid)
184  await refreshPlan($)
185  await reconcile($)
186}
187
188async function activeSteps($: $) {
189  const fromTasks = await read($, tasks)
190  if (fromTasks.length > 0) return fromTasks
191  return (await isEngaged($)) ? await read($, plan) : []
192}
193
194async function reconcile($: $) {
195  const steps = await activeSteps($)
196  const before = await read($, job)
197  const now = await $.clock.now()
198  let after = before
199
200  if (steps.length === 0) {
201    after = { startedAt: null, doneAt: null }
202  } else if (!steps.every(isDone)) {
203    if (before.startedAt === null || before.doneAt !== null) after = { startedAt: now, doneAt: null }
204  } else if (before.startedAt !== null && before.doneAt === null) {
205    after = { startedAt: before.startedAt, doneAt: now }
206    $.ui.toast('✓ Tamamlandı')
207    $.clock.after(HIDE_MS + 100, () => $.ui.invalidate('ui.render'))
208  }
209
210  if (after !== before) await update($, job, () => after)
211}
212
213async function tick($: $) {
214  if ((await read($, tasks)).length === 0 && (await isEngaged($))) await refreshPlan($)
215  await reconcile($)
216}
217
218const isOtherSession = async ($: $, id: string | undefined) => {
219  const sid = await sessionId($)
220  return Boolean(id) && Boolean(sid) && id !== sid
221}
222
223export const register: Register = on => {
224  on('session.start', async ($, e, next) => {
225    await $.command.register({
226      name: 'ilerleme',
227      description: 'İlerleme çubuğunu gizler, gösterir ya da adım listesini açar',
228      argumentHint: 'gizle | göster | detay',
229      immediate: true,
230    })
231    ticker?.cancel()
232    ticker = $.clock.every(TICK_MS, () => void tick($).catch(() => undefined))
233    await tick($).catch(() => undefined)
234
235    return next(e)
236  })
237
238  on('command.run', { command: 'ilerleme' }, async ($, e) => {
239    // "GİZLE", "GIZLE", "Göster" and "goster" all work
240    const arg = e.args.trim().toLocaleLowerCase('tr').replace(/ı/g, 'i')
241
242    if (arg === 'gizle') {
243      await update($, isHidden, () => true)
244      return { text: 'İlerleme çubuğu gizlendi. Geri getirmek için: /ilerleme göster' }
245    }
246    if (arg === 'göster' || arg === 'goster') {
247      await update($, isHidden, () => false)
248      return { text: 'İlerleme çubuğu gösteriliyor.' }
249    }
250    if (arg === 'detay') {
251      const open = await update($, isExpanded, value => !value)
252      return { text: open ? 'Adım listesi açıldı.' : 'Adım listesi kapatıldı.' }
253    }
254
255    return { text: USAGE }
256  })
257
258  on('tool.call', async ($, e, next) => {
259    const ran = await next(e)
260    if (ran.deny !== undefined || ran.isError === true) return ran
261
262    const tool = String(e.tool)
263    const args = e as unknown as Args
264    // TaskCreate/TaskUpdate share the session's single task list, so a sub-agent's
265    // calls count; a sub-agent's TodoWrite is its own private list and must not
266    // replace the main one.
267    if (tool === 'TodoWrite' && e.agentId !== undefined) return ran
268
269    try {
270      if (tool === 'TodoWrite') {
271        await update($, tasks, () => fromTodos(args.todos))
272      } else if (tool === 'TaskCreate') {
273        const id = createdId(ran) ?? `tu:${e.tool_use_id}`
274        const step: Adim = {
275          id,
276          content: text(args.subject) || text(args.description),
277          activeForm: text(args.activeForm),
278          status: 'pending',
279        }
280        await update($, tasks, list => upsert(list, step))
281      } else if (tool === 'TaskUpdate') {
282        await update($, tasks, list => patch(list, String(args.taskId), args))
283      } else if (Object.values(args).some(value => typeof value === 'string' && WORKFLOW.test(value))) {
284        await engage($)
285        return ran
286      } else {
287        return ran
288      }
289      await reconcile($)
290    } catch {
291      // the tool already ran; a failed progress update must not fail it
292    }
293
294    return ran
295  })
296
297  on('prompt.submit', async ($, e, next) => {
298    const ran = await next(e)
299    if (ran.drop === undefined && WORKFLOW.test(e.text)) await engage($).catch(() => undefined)
300
301    return ran
302  })
303
304  on('skill.prompt', async ($, e, next) => {
305    const ran = await next(e)
306    if (ENGAGING_SKILLS.has(e.skill.split(':').pop() ?? '')) await engage($).catch(() => undefined)
307
308    return ran
309  })
310
311  on('classic.TaskCreated', async ($, e, next) => {
312    if (await isOtherSession($, e.session_id)) return next(e)
313    const step: Adim = {
314      id: e.task_id,
315      content: e.task_subject,
316      activeForm: '',
317      status: 'pending',
318    }
319    // a failure here must never hold up Claude's own task flow
320    await update($, tasks, list => upsert(list, step))
321      .then(() => reconcile($))
322      .catch(() => undefined)
323
324    return next(e)
325  })
326
327  on('classic.TaskCompleted', async ($, e, next) => {
328    if (await isOtherSession($, e.session_id)) return next(e)
329    await update($, tasks, list => patch(list, e.task_id, { status: 'completed' }))
330      .then(() => reconcile($))
331      .catch(() => undefined)
332
333    return next(e)
334  })
335
336  on('turn.complete', async ($, e, next) => {
337    await tick($).catch(() => undefined)
338
339    return next(e)
340  })
341
342  // A /clear (e.g. otomatik-devir's switch) or a resume goes on in this process as
343  // another conversation, with no session.start: nothing of the old one carries over.
344  on('session.end', async ($, e, next) => {
345    seen = ''
346    await update($, tasks, () => [])
347    await update($, plan, () => [])
348    await update($, job, () => ({ startedAt: null, doneAt: null }))
349    await update($, engagedSid, () => null)
350
351    return next(e)
352  })
353
354  // AbovePrompt is one band shared with otomatik-devir and blast-radius (its narrow-
355  // terminal fallback). Nothing to show: pass with next(e) untouched. Otherwise compose,
356  // never replace: whatever the plugins beneath drew comes FIRST (a handoff question, a
357  // held command), then this mod's rows, so an opened step list can never push another
358  // mod's buttons out of the band's window. No hotkey, no autoFocus: the keys stay theirs.
359  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
360    const mine = await ownRows($, e)
361    if (mine === undefined) return next(e)
362
363    const below = await next(e)
364    const { Box } = $.ui.resolve(e)
365    return (
366      <Box flexDirection="column">
367        {below ?? null}
368        {mine}
369      </Box>
370    )
371  })
372}
373
374async function ownRows($: $, e: RenderInput<'AbovePrompt'>): Promise<RenderElement[] | undefined> {
375  if (e.component !== 'AbovePrompt') return undefined
376  if (e.props.hasSurvey || (await read($, isHidden))) return undefined
377
378  const steps = await activeSteps($)
379  const { startedAt, doneAt } = await read($, job)
380  if (steps.length === 0 || startedAt === null) return undefined
381
382  const now = await $.clock.now()
383  const done = steps.filter(isDone).length
384  const isComplete = done === steps.length
385  if (isComplete && (doneAt === null || now - doneAt > HIDE_MS)) return undefined
386
387  const total = steps.length
388  const head = `${bar(done, total)} ${done}/${total} · %${Math.round((done / total) * 100)} · `
389  const step = current(steps)
390  const room = Math.max(8, e.props.bodyColumns - head.length - 9)
391  const middle = isComplete || !step ? '✓ Tamamlandı' : `Şu an: ${clip((step.status === 'in_progress' && step.activeForm) || step.content, room)}`
392  const expanded = await read($, isExpanded)
393
394  const { Button, Text } = $.ui.resolve(e)
395
396  return [
397    <Button key="toggle" label={head + middle} plain onPress={() => update($, isExpanded, value => !value)} />,
398    ...(expanded
399      ? steps.map(one => (
400          <Text key={`adim-${one.id}`} dimColor={isDone(one)} bold={one === step} wrap="truncate-end">
401            {isDone(one) ? '✓' : one === step ? '●' : '○'} {one.content}
402          </Text>
403        ))
404      : []),
405  ]
406}
407
hooks/progress.ts 290 lines
1// Pure parsers for the progress bar: text in, steps out. No I/O here; register.tsx
2// reads the files and hands their text to these functions.
3// Spec: .workflow/research/progress-formats.md (my-skill first, then a generic ladder).
4
5import type { IlerlemeCubuguAdim as Adim, IlerlemeCubuguDurum as Durum } from '../types'
6
7export type MsPlan = {
8  ids: string[]
9  titles: Map<string, string>
10  done: Set<string>
11}
12
13export type StateInfo = {
14  done: Set<string>
15  inProgress: Set<string>
16  phase: string
17  activeSkill: string
18}
19
20const MS_ID = /^[ \t]{0,3}[-*+][ \t]+id:[ \t]*([A-Z]{1,3}\d{1,4}[a-z]{0,2})[ \t]*(?:#.*)?$/
21const MS_TITLE = /^[ \t]{2,6}(?:goal|title):[ \t]*(.+)$/
22const MS_STATUS_DONE = /^[ \t]{2,6}status:[ \t]*(?:\[x\][ \t]*)?(?:DONE|done|✅|tamam(?:land[ıi])?)(?!\p{L})/u
23const CHECKED = /^[ \t]{0,3}[-*+][ \t]+\[[xX]\][ \t]+(.*)$/
24const HEADING = /^[ \t]{0,3}(#{1,6})[ \t]+(.*)$/
25const BULLET = /^([ \t]?)[-*+][ \t]+(.*)$/
26const RANGE = /[A-Z]{1,3}\d+[a-z]{0,2}[ \t]*(?:\.\.|–|—|→|-)[ \t]*[A-Z]{1,3}\d+[a-z]{0,2}/g
27const SHA_AFTER = '(?=[ \\t]+(?=[0-9a-f]*\\d)[0-9a-f]{7,40}\\b)'
28const PLAN_PATH = /(?:^|[^\w-])(?:\.workflow\/)?plans\/([^/\s`'")\]]+)\/PLAN\.md/g
29
30const lines = (source: string) => source.replace(/\r\n?/g, '\n').split('\n')
31
32export const plainText = (value: string) => value.replace(/\*\*|__|`|~~/g, '').trim()
33
34// Turkish-aware fold that keeps English words intact (tr's own 'I' → 'ı' would not)
35export const fold = (value: string) => value.replace(/[İI]/g, 'i').toLowerCase()
36
37export type Section = 'done' | 'in_progress' | 'remaining' | 'none'
38
39const SECTION_DONE = /^(done|completed|finished|merged|tamamland|tamamlanan|bitti|biten|yap[ıi]ld[ıi]|yap[ıi]lan|birleş)/
40const SECTION_RUNNING = /^(in[ -]progress|doing|wip|active|devam ediyor|sürüyor|sürmekte|yap[ıi]l[ıi]yor|çal[ıi]ş[ıi]l[ıi]yor|şu an)/
41const SECTION_REMAINING = /^(remaining|to[ -]?do|pending|backlog|next|up next|kalan|yap[ıi]lacak|bekleyen|s[ıi]rada|s[ıi]radaki)/
42
43export const sectionOf = (heading: string): Section => {
44  const name = fold(heading.replace(/^[^\p{L}]+/u, ''))
45  if (SECTION_DONE.test(name)) return 'done'
46  if (SECTION_RUNNING.test(name)) return 'in_progress'
47  if (SECTION_REMAINING.test(name)) return 'remaining'
48  return 'none'
49}
50
51// Calls `visit` for every top-level bullet with the section it sits in.
52const walkSections = (source: string, visit: (section: Section, text: string) => void) => {
53  let section: Section = 'none'
54  let level = 0
55  for (const line of lines(source)) {
56    const heading = HEADING.exec(line)
57    if (heading) {
58      const depth = (heading[1] ?? '').length
59      const found = sectionOf(heading[2] ?? '')
60      if (found !== 'none' || section === 'none' || depth <= level) {
61        section = found
62        level = depth
63      }
64      continue
65    }
66    const bullet = BULLET.exec(line)
67    if (bullet) visit(section, bullet[2] ?? '')
68  }
69}
70
71const shortTitle = (goal: string) => {
72  const text = plainText(goal)
73  const cuts = [' (AUD-', '. ', '; '].map(mark => text.indexOf(mark)).filter(at => at > 0)
74  return (cuts.length > 0 ? text.slice(0, Math.min(...cuts)) : text).trim()
75}
76
77const idPattern = (ids: Iterable<string>, tail = '') => {
78  const sorted = [...ids].sort((a, b) => b.length - a.length)
79  return new RegExp(`(?<![\\w/.\\-])(${sorted.join('|')})(?![\\w\\-/])${tail}`, 'g')
80}
81
82const idsIn = (text: string, pattern: RegExp) => [...text.matchAll(pattern)].map(match => match[1] ?? '')
83
84// The ids a bullet is about: its head, before the first dash or parenthesis; of its
85// colon segments, the first that names a plan id ("W4: T1 T15" → T1 T15).
86export const leadIds = (bullet: string, ids: Set<string>) => {
87  if (ids.size === 0) return []
88  const pattern = idPattern(ids)
89  const head = plainText(bullet.replace(/^\[[^\]]\][ \t]*/, ''))
90    .replace(RANGE, ' ')
91    .split(/ — | – | - |\(/)[0] ?? ''
92  for (const segment of head.split(':')) {
93    const found = idsIn(segment, pattern)
94    if (found.length > 0) return found
95  }
96  return []
97}
98
99// A my-skill plan: `- id: T1` lines (at least two), `goal:` titles, `status: DONE`
100// fields and a `- [x] T1 — …` ledger as the plan's own record of what is done.
101export const parseMsPlan = (source: string): MsPlan | undefined => {
102  const ids: string[] = []
103  const titles = new Map<string, string>()
104  const done = new Set<string>()
105  const ledger: string[] = []
106  let at: string | undefined
107  for (const line of lines(source)) {
108    const id = MS_ID.exec(line)?.[1]
109    if (id) {
110      if (!ids.includes(id)) ids.push(id)
111      at = id
112      continue
113    }
114    const checked = CHECKED.exec(line)
115    if (checked) ledger.push(checked[1] ?? '')
116    if (!at) continue
117    const title = MS_TITLE.exec(line)?.[1]
118    if (title && !titles.has(at)) titles.set(at, shortTitle(title))
119    if (MS_STATUS_DONE.test(line)) done.add(at)
120  }
121  if (ids.length < 2) return undefined
122  const set = new Set(ids)
123  for (const text of ledger) for (const id of leadIds(text, set)) done.add(id)
124  return { ids, titles, done }
125}
126
127// STATE.md, read against a plan's id set: Done (sha pairs + lead ids), In progress
128// (lead ids), and the header's Phase / Active skill. Ids outside the plan are dropped.
129export const parseState = (source: string, ids: Set<string>): StateInfo => {
130  const done = new Set<string>()
131  const inProgress = new Set<string>()
132  let phase = ''
133  let activeSkill = ''
134  for (const line of lines(source)) {
135    const phaseAt = /^Phase:[ \t]*(.*)$/.exec(line)
136    if (phaseAt && !phase) phase = phaseAt[1] ?? ''
137    const skillAt = /^Active skill:[ \t]*(.*)$/.exec(line)
138    if (skillAt && !activeSkill) activeSkill = skillAt[1] ?? ''
139  }
140  if (ids.size > 0) {
141    const withSha = idPattern(ids, SHA_AFTER)
142    walkSections(source, (section, text) => {
143      if (section === 'done') {
144        for (const id of idsIn(text.replace(RANGE, ' '), withSha)) done.add(id)
145        for (const id of leadIds(text, ids)) done.add(id)
146      } else if (section === 'in_progress') {
147        for (const id of leadIds(text, ids)) inProgress.add(id)
148      }
149    })
150  }
151  return { done, inProgress, phase, activeSkill }
152}
153
154const TERMINAL_PHASE = /^(tamam(land[ıi])?|bitti|complete(d)?|done|finished|shipped)(?![\p{L}\d])/u
155
156// Steps for a my-skill plan; [] when no job is active (spec §4). An all-done plan
157// is returned as such, so reconcile can flash "✓ Tamamlandı" for a job it saw run.
158export const mySkillSteps = (plan: MsPlan, state?: StateInfo): Adim[] => {
159  const status = (id: string): Durum =>
160    plan.done.has(id) || state?.done.has(id) ? 'completed' : state?.inProgress.has(id) ? 'in_progress' : 'pending'
161  const steps = plan.ids.map(id => {
162    const title = plan.titles.get(id)
163    return { id, content: title ? `${id} · ${title}` : id, activeForm: '', status: status(id) }
164  })
165  const done = steps.filter(one => one.status === 'completed').length
166  const running = steps.some(one => one.status === 'in_progress')
167  const skill = fold(state?.activeSkill ?? '')
168  if (!running && TERMINAL_PHASE.test(fold(state?.phase ?? '')) && /^(none|my-skill-ship)\b/.test(skill)) return []
169  if (done === 0 && !running && !/^my-skill-execute\b/.test(skill)) return []
170  return steps
171}
172
173// Which plan dir STATE.md names, best first (spec §2.3 steps 1-3). `dirs` are the
174// existing .workflow/plans/<dir> names; anything else is ignored.
175export const planDirsFromState = (source: string, dirs: string[]): string[] => {
176  const known = new Set(dirs)
177  const found: string[] = []
178  const add = (dir: string | undefined) => {
179    if (dir && known.has(dir) && !found.includes(dir)) found.push(dir)
180  }
181  const all = lines(source)
182
183  for (const line of all) {
184    const plan = /^\s*[-*]?\s*Plan:\s*(\S+)/.exec(line)?.[1]
185    if (plan) add(/(?:^|\/)plans\/([^/\s`'")\]]+)\/PLAN\.md/.exec(plan)?.[1])
186  }
187
188  const counts = new Map<string, number>()
189  const inFiles = new Set<string>()
190  let isFiles = false
191  for (const line of all) {
192    const heading = HEADING.exec(line)
193    if (heading) isFiles = /^files\b/.test(fold(heading[2] ?? ''))
194    for (const match of line.matchAll(PLAN_PATH)) {
195      const dir = match[1] ?? ''
196      counts.set(dir, (counts.get(dir) ?? 0) + 1)
197      if (isFiles) inFiles.add(dir)
198    }
199  }
200  const mentioned = [...counts.keys()]
201  ;[...mentioned]
202    .sort((a, b) => Number(inFiles.has(b)) - Number(inFiles.has(a)) || (counts.get(b) ?? 0) - (counts.get(a) ?? 0))
203    .forEach(add)
204
205  const header = all.slice(0, Math.max(0, all.findIndex(line => /^##\s/.test(line)))).join('\n') || source
206  const goal = all.filter(line => /^Goal:/.test(line)).join('\n')
207  for (const text of [goal, header]) {
208    for (const dir of dirs) {
209      if (text.includes(`${dir} PLAN.md`) || text.includes(`plans/${dir}/`)) add(dir)
210    }
211  }
212  return found
213}
214
215// --- generic ladder (spec §5) ---
216
217// any indent, as before the my-skill parser: nested sub-task boxes count too
218const BOX = /^[ \t]*[-*+][ \t]+\[([ xX✓✔~>/.-])\][ \t]+(.*)$/
219
220// L1 (and L4's checkbox part): `[x]` done, `[~] [>] [/] [.]` running, `[-]` dropped;
221// an open box under an in-progress heading is running.
222export const checkboxSteps = (source: string): Adim[] => {
223  let section: Section = 'none'
224  return lines(source).flatMap((line, i) => {
225    const heading = HEADING.exec(line)
226    if (heading) {
227      section = sectionOf(heading[2] ?? '')
228      return []
229    }
230    const match = BOX.exec(line)
231    if (!match) return []
232    const mark = match[1] ?? ' '
233    if (mark === '-') return []
234    const status: Durum = /[xX✓✔]/.test(mark)
235      ? 'completed'
236      : mark !== ' ' || section === 'in_progress'
237        ? 'in_progress'
238        : 'pending'
239    return [{ id: `plan-${i}`, content: plainText(match[2] ?? ''), activeForm: '', status }]
240  })
241}
242
243// whole words only: "tamamlanmadı" (not finished) and "tamamen" are no done marks
244const MARK_DONE = /✅|✔|☑|\bDONE\b|\[done\]|\(done\)|(?<!\p{L})(tamam(?:land[ıi])?|bitti)(?!\p{L})/u
245const MARK_RUNNING = /🔄|⏳|🚧|\bIN PROGRESS\b|\[wip\]|devam ediyor|sürüyor|←\s*(now|current|şimdi)/u
246const NUMBERED_HEADING =
247  /^[ \t]{0,3}#{2,4}[ \t]+(?:(?:Task|Step|Görev|Adım|Faz|Phase|Aşama)[ \t]+)?(\d+|[A-Z]\d+)[.):\-—][ \t]*(.+)$/
248const ORDERED = /^[ \t]{0,3}(\d+)[.)][ \t]+(.+)$/
249
250const markOf = (title: string): Durum | undefined =>
251  /^~~/.test(title.trim()) || MARK_DONE.test(title) ? 'completed' : MARK_RUNNING.test(title) ? 'in_progress' : undefined
252
253// L2: numbered headings or an ordered list, only with ≥3 items and ≥1 status marker.
254export const numberedSteps = (source: string): Adim[] => {
255  for (const pattern of [NUMBERED_HEADING, ORDERED]) {
256    const items = lines(source).flatMap((line, i) => {
257      const title = pattern.exec(line)?.[2]
258      return title ? [{ i, title, mark: markOf(title) }] : []
259    })
260    if (items.length >= 3 && items.some(item => item.mark !== undefined)) {
261      return items.map(({ i, title, mark }) => ({
262        id: `plan-${i}`,
263        content: plainText(title),
264        activeForm: '',
265        status: mark ?? 'pending',
266      }))
267    }
268  }
269  return []
270}
271
272// L4's fallback: a STATE.md with sections but no checkboxes; plain top-level bullets.
273export const stateBulletSteps = (source: string): Adim[] => {
274  const steps: Adim[] = []
275  const status = { done: 'completed', in_progress: 'in_progress', remaining: 'pending' } as const
276  walkSections(source, (section, text) => {
277    if (section === 'none') return
278    steps.push({ id: `plan-${steps.length}`, content: plainText(text), activeForm: '', status: status[section] })
279  })
280  return steps.length >= 2 ? steps : []
281}
282
283export const genericSteps = (source: string, isState: boolean): Adim[] => {
284  const boxes = checkboxSteps(source)
285  if (boxes.length > 0) return boxes
286  const numbered = numberedSteps(source)
287  if (numbered.length > 0) return numbered
288  return isState ? stateBulletSteps(source) : []
289}
290
types/index.d.ts 25 lines
1export type IlerlemeCubuguDurum = 'pending' | 'in_progress' | 'completed'
2
3export type IlerlemeCubuguAdim = {
4  id: string
5  content: string
6  activeForm: string
7  status: IlerlemeCubuguDurum
8}
9
10export type IlerlemeCubuguIs = { startedAt: number | null; doneAt: number | null }
11
12declare module 'claude-code' {
13  interface PluginState {
14    'ilerleme-cubugu': {
15      tasks: IlerlemeCubuguAdim[]
16      plan: IlerlemeCubuguAdim[]
17      job: IlerlemeCubuguIs
18      isHidden: boolean
19      isExpanded: boolean
20      /** the session id of the conversation that engaged the plan-file bar */
21      engagedSid: string | null
22    }
23  }
24}
25