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…

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
| Mod | Ne yapar |
|---|---|
| otomatik-devir | Sohbetin 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-cubugu | Sü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.
Gerekli: Claude Code 2.1.287 veya üstü. Terminalde claude --version yazıp bakın; eskiyse claude update.
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.
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.
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.
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.
| Komut | Ne yapar |
|---|---|
/devir | Durumu gösterir |
/devir esik 70 | Eşiği %70 yapar (1-99) |
/devir otomatik | Sormadan yeni sohbete geçer |
/devir sor | Geri sorarak geçmeye döner (varsayılan) |
/devir simdi | Devri hemen başlatır |
/devir gec | Bekleyen devirde yeni sohbete geçer |
/devir kapat / /devir ac | Modu 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.
İki kaynaktan çubuk çizer:
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.
| Komut | Ne yapar |
|---|---|
/ilerleme detay | Adı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.
claude plugin uninstall otomatik-devir@muctebadikmen --scope user
claude plugin uninstall ilerleme-cubugu@muctebadikmen --scope user
claude plugin marketplace remove muctebadikmen
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.
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.Her modun testleri modlar/<mod>/tests/ içinde. Çalıştırmak için mod klasöründe:
claude plugin test .
MIT. Kullanın, değiştirin, paylaşın. Sorun görürseniz bir issue açın ya da Skool topluluğunda yazın.
hooks/register.tsx 407 lines1import { 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}
407hooks/progress.ts 290 lines1// 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}
290types/index.d.ts 25 lines1export 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