Proposes the loose ends Claude leaves undone and lets you decide which to keep; keeps them in refs/loose-ends without touching your branches, and Chispa flags…

The session's notebook for Claude Code, with Chispa. English and Spanish.
note_loose_end tool and a category (bug, debt, test, warning, improvement), and Haiku (claude-haiku-4-5-20251001) proposes it after every answer of 500 characters or more, quoting the sentence literally. Before that, a fixed filter drops what is not work on the code (checking, trying, running, waiting, deciding, notifying, pushing or merging, deploying, sending to third parties) in English and in Spanish, repeats, and anything like what you already rejected. In English, the verbs verify, confirm, wait, decide, inform, notify, ask and tell drop a sentence on their own; the ambiguous ones (push, deploy, check, run, test, review, report, watch, monitor) only with their second word ("push to", "check that", "run the tests"), so "check the null case in parse()" is still work. From the sweep it also drops what the whole sentence says is done, what it says is already being done, what it leaves to you as a decision, and what looks like something already waiting. The sweep never proposes urgent ones, and what is put off on purpose ("later", "phase 5") arrives as low. If Claude already noted a loose end with the tool that turn, the sweep only looks for closures.Save, Not a loose end or Edit. What you do not review waits in the Notebook and expires after 7 days. What the sweep proposes waits one turn before showing, longer while subagents work (up to 30 minutes). If a later answer, or the subject of a commit of the turn, shows it done, it goes away by itself: the card says "Withdrawn: resolved later" with Undo, and ↺ in Closed this week brings it back to review. Every "Not a loose end" teaches the sweep: the last 20 go into its prompt.Do it it always reads the answer, long or short.refs/loose-ends/items of the git repo each loose end belongs to, outside your branches. They do not show in git status, make no commits on your branch, and every worktree of the repo sees the same list. With origin, they are fetched when a session starts and pushed after a git push Claude runs in the session: the first time it asks, and Always or Never is kept in git config loose-ends.sync. If you push from your own terminal, the Notebook offers Push. Closed loose ends are deleted 30 days after closing, and the 50 newest rejected are kept. Outside a git repo nothing is stored (never in ~/.claude/).View); candidates to review (orange border, Review); a loose end just closed (green border, what is left, Do it on the next one and Undo); what changed since the last session; otherwise the next loose end (the most urgent; at equal priority the one in progress, then the oldest) with Do it to start it, how many there are of each priority and whether they are pushed to origin, with a red border when it is urgent. If you press Do it while Claude is answering, a toast says "I'll start it when Claude finishes" and the loose end starts 500 ms after the turn ends. With nothing open, Chispa says a short phrase that depends on whether Claude is working, whether she is asleep, how many you closed this week and the time of day. In the terminal, one line: Can't read the loose ends · View, N to review · Review, Urgent: … · View, Closed: … · Undo, Since yesterday: … · View, N open · View, or Chispa's phrase.git commit, works during the turn, and only sleeps after 30 quiet minutes with nothing pending./loose-ends in English, /pendientes in Spanish, or the band's action; the pane is called "Notebook" in English and "Cuaderno" in Spanish): To review, Open (the priority changes with a tap, edit, Do it, Done, Dismiss, and "Still relevant?" after 14 days without changes) and Closed this week with ↺. The field at the top notes a loose end by hand. On mobile it looks the same, without text fields.Tab takes it.Requires Claude Code 2.1.287 or later (tested with 2.1.288).
The language option: auto (default), es or en, in /config. With auto, the first of LC_ALL, LC_MESSAGES and LANG that is set decides and, on macOS, where none is, the system language does: Spanish when it starts with es, English otherwise. es or en force a language. The tool accepts the categories in both languages (debt, warning, improvement are stored as deuda, aviso, mejora), so loose ends written in one language read the same in the other. The debug log is always in English.
/plugin marketplace add MiguelProz/claude-loose-ends /plugin install loose-ends@claude-loose-ends /reload-plugins
Claude Code does not know there is a new version until it refreshes its copy of the marketplace. First the marketplace, then the plugin:
claude plugin marketplace update claude-loose-ends claude plugin update loose-ends@claude-loose-ends
The first session in each repo imports .claude/loose-ends.json into refs/loose-ends/items and says so once in the Notebook. Then you can delete the file with git rm .claude/loose-ends.json.
git show refs/loose-ends/items:loose-ends.json
What origin had the last time they were fetched or pushed is in refs/loose-ends/origin (outside refs/remotes, so it does not show in git branch -r). Neither shows on GitHub's web pages.
/plugin → Installed → loose-ends. The data stays in refs/loose-ends/items of each repo.
hooks/register.mjs 1052 lines1import {
2 COMMON_DIR_ARGS, FETCH_ARGS, GIT_ENV, HASH_ARGS, NET_ENV, REF, REMOTE_ARGS, REMOTE_REF, SYNC_GET_ARGS, TREE_ARGS,
3 blobArgs, commitArgs, firstLine, hasOrigin, lastCommitArgs, turnLogArgs, mergeItems, pushArgs, sameItems, shaArgs, syncSetArgs, trackArgs, treeInput, updateArgs,
4} from '../lib/refstore.mjs'
5import {
6 LEGACY_FILE, canonicalCategory, addManual, candidates, closedRecently, confirmClose, counts, dismiss, editText, expireCandidates, isStale, keepOpen, live,
7 markDone, nextItem, parseItems, propose, proposeClose, prune, reject, rejectedTexts, reopen, restore, save, serializeItems, setPriority, start, suggestable, topUrgent, touch, recap, snapshot,
8 heldCandidates, release, unwithdraw, withdraw,
9} from '../lib/items.mjs'
10import { rejectReason, rejectText } from '../lib/filter.mjs'
11import { SWEEP_MODEL, buildSweepPrompt, containsQuote, parseSweepReply, shouldSweep } from '../lib/detect.mjs'
12import {
13 TOOL_NAME, doNowText, formatContext, noGitNote, notARepo, passingText, suggestPrefix, suggestText, sweepSystem, toolDescription, toolGuide, toolProposed, toolRejected,
14 toolSchema, toolTooShort, toolUnreadable,
15} from '../lib/texts.mjs'
16import { FLASH_MS, bashFailed, chispaMood, isCommit, isPush } from '../lib/mood.mjs'
17import { FILE_TOOLS, candidatePaths, candidateRepos, isAbsolutePath, isIgnoredRepo, normalizePath, parentPath, relativeTo, repoName } from '../lib/repos.mjs'
18import { emphasize, localClock, renderBand, renderPane, renderTriage, sinceText } from '../lib/screens.mjs'
19import { pickLanguage, t, tn } from '../lib/i18n.mjs'
20
21const MAX_WALK = 3
22const MAX_CACHE = 500
23const MAX_PATHS_PER_CALL = 10
24const MAX_TOUCHED = 6
25const MAX_TRIES = 3
26const NEXT_PRIORITY = { high: 'medium', medium: 'low', low: 'high' }
27
28let items = []
29let fileError = null
30let branch = null
31// Toplevel of the repo the session works in; null outside git, where nothing of the session is persisted.
32let sessionRepo = null
33let homePath
34let touched = new Set()
35let touchChain = Promise.resolve()
36const repoCache = new Map()
37let writeChain = Promise.resolve()
38let working = false
39// the item a Hacer pressed during a turn starts once that turn ends
40let queuedNow = null
41let lastActivity = 0
42let flashUntil = 0
43// The last item the person closed, as it was before, for the band's Deshacer while it lasts.
44let justClosed = null
45// The item whose text an Input edits, in the pane or in its card; one at a time.
46let editing = null
47// What the pane says once about the import of the 0.3 file.
48let notice = null
49// Where the session repo's ref stands against origin: 'local', 'synced', 'ahead' or 'failed'.
50let sync = null
51// When the current turn started (ISO); the commit the sweep offers as proof is the last one made since then.
52let turnStartedAt = null
53// Subagents whose turn started and has not ended; while any runs, what the sweep holds back stays held.
54const runningAgents = new Set()
55// Whether Claude noted a loose end by hand this turn; the sweep then only looks for closures.
56let notedThisTurn = false
57// How long the sweep holds a candidate back at most, whatever subagent still seems to run.
58const HOLD_MAX_MS = 30 * 60 * 1000
59// Cards the person answered under a message: id -> { kind, quote, state, previous }.
60const triage = new Map()
61let refreshTimer = null
62// «Desde …» for the band during the first turn: { since, fresh, closed }, or null.
63let recapNow = null
64// Whether this session already compared with the last one; until then nothing overwrites the old snapshot.
65let seenReady = false
66// Whether a turn has ended in this session; a late compare then no longer shows «Desde …».
67let firstTurnDone = false
68// Files whose open loose ends Claude was already told about this turn, relative to the session repo.
69const passed = new Set()
70const SEEN_FILE = 'loose-ends-seen.json'
71// The `language` option as register() receives it, and the language every text is drawn and sent in.
72let languageOption = 'auto'
73let lang = 'en'
74let languageReady = false
75
76// Fixes the language once per load: the option when it says es or en, otherwise (auto) the locale variables and, when
77// they are all empty (a desktop app on macOS), the system language from `defaults`. Every hook awaits it first, so no
78// text is drawn or sent before it is known; a change of option reloads the module.
79async function resolveLanguage($) {
80 if (languageReady) return
81 let env = {}
82 let source = 'option'
83 if (languageOption !== 'es' && languageOption !== 'en') {
84 source = 'default'
85 try {
86 env = { LC_ALL: await $.env.get('LC_ALL'), LC_MESSAGES: await $.env.get('LC_MESSAGES'), LANG: await $.env.get('LANG') }
87 } catch {}
88 const set = name => typeof env[name] === 'string' && env[name].trim() !== ''
89 if (set('LC_ALL') || set('LC_MESSAGES') || set('LANG')) {
90 source = set('LC_ALL') ? 'LC_ALL' : set('LC_MESSAGES') ? 'LC_MESSAGES' : 'LANG'
91 } else {
92 try {
93 const r = await $.process.run(['defaults', 'read', '-g', 'AppleLocale'], { timeoutMs: 2000 })
94 const out = typeof r?.stdout === 'string' ? r.stdout.trim() : ''
95 if (r?.exitCode === 0 && out !== '') {
96 env.APPLE_LOCALE = out
97 source = 'AppleLocale'
98 }
99 } catch {}
100 }
101 }
102 lang = pickLanguage(languageOption, env)
103 languageReady = true
104 await logDebug($, `loose-ends: language ${lang} (${source === 'default' ? 'default' : `from ${source}`})`)
105}
106
107async function logDebug($, message) {
108 try {
109 await $.ui.log(message, { to: 'debug' })
110 } catch {}
111}
112
113function background($, promise, what) {
114 promise.catch(err => logDebug($, `loose-ends: ${what} failed (${err?.message ?? err})`))
115}
116
117// Runs one step that must not break the hook: a failure is logged and the next step still runs.
118async function guarded($, what, step) {
119 try {
120 await step()
121 } catch (err) {
122 await logDebug($, `loose-ends: ${what} failed (${err?.message ?? err})`)
123 }
124}
125
126async function nowIso($) {
127 return new Date(await $.clock.now()).toISOString()
128}
129
130// A repo's loose ends and the commit they came from; `sha` is null when the ref does not exist yet.
131async function readRef($, root, ref = REF) {
132 const head = await $.process.run(['git', '-C', root, ...shaArgs(ref)])
133 if (head.exitCode !== 0) return { ok: true, items: [], sha: null }
134 const sha = firstLine(head.stdout)
135 if (!sha) return { ok: false, error: 'git' }
136 const blob = await $.process.run(['git', '-C', root, ...blobArgs(sha)])
137 if (blob.exitCode !== 0) return { ok: false, error: 'blob' }
138 const parsed = parseItems(blob.stdout)
139 return parsed.ok ? { ...parsed, sha } : parsed
140}
141
142// Points the ref at a new commit holding `list`; false when another session moved it since `prev` was read.
143async function writeRef($, root, list, prev) {
144 const blob = await $.process.run(['git', '-C', root, ...HASH_ARGS], { stdin: serializeItems(list) })
145 const blobSha = blob.exitCode === 0 ? firstLine(blob.stdout) : null
146 if (!blobSha) throw new Error('git hash-object failed')
147 const tree = await $.process.run(['git', '-C', root, ...TREE_ARGS], { stdin: treeInput(blobSha) })
148 const treeSha = tree.exitCode === 0 ? firstLine(tree.stdout) : null
149 if (!treeSha) throw new Error('git mktree failed')
150 const commit = await $.process.run(['git', '-C', root, ...commitArgs(treeSha, prev)], { env: GIT_ENV })
151 const commitSha = commit.exitCode === 0 ? firstLine(commit.stdout) : null
152 if (!commitSha) throw new Error('git commit-tree failed')
153 const moved = await $.process.run(['git', '-C', root, ...updateArgs(commitSha, prev)])
154 return moved.exitCode === 0
155}
156
157// Read-modify-write of a repo's loose ends, again from the top when another session moved the ref in between.
158// `fn` may run more than once, so it must not have side effects beyond what it returns.
159async function commitItems($, root, fn) {
160 const now = await $.clock.now()
161 for (let tries = 0; tries < MAX_TRIES; tries++) {
162 const read = await readRef($, root)
163 if (!read.ok) return { error: read.error }
164 const result = fn(read.items)
165 // every write also drops what the ref no longer keeps (old closed items, rejected beyond the newest 50)
166 const nextItems = prune(Array.isArray(result) ? result : result.items, now)
167 if (serializeItems(nextItems) === serializeItems(read.items)) return { result, items: read.items, written: false }
168 if (await writeRef($, root, nextItems, read.sha)) return { result, items: nextItems, written: true }
169 }
170 return { error: 'busy' }
171}
172
173// Loads the session repo's items into memory; outside git there is nothing to read and nothing is touched.
174async function load($) {
175 if (!sessionRepo) {
176 items = []
177 fileError = null
178 return items
179 }
180 const parsed = await readRef($, sessionRepo)
181 if (!parsed.ok) {
182 fileError = parsed.error
183 return null
184 }
185 fileError = null
186 items = parsed.items
187 return items
188}
189
190// Read-modify-write on a repo's ref (the session's by default, resolved when the write runs); callers go through
191// `mutate`, which serializes them. `fn(list, isSession)` says whether the ref was the session's when it ran;
192// `report.error` gets why an unreadable or busy ref was left alone. Only the session repo's items live in memory.
193async function mutateNow($, fn, root, report) {
194 const target = root === undefined ? sessionRepo : root
195 if (target === null) {
196 await load($)
197 $.ui.invalidate('ui.render')
198 return null
199 }
200 const isSession = target === sessionRepo
201 const done = await commitItems($, target, list => fn(list, isSession))
202 if (done.error) {
203 if (report) report.error = done.error
204 if (isSession) {
205 fileError = done.error
206 $.ui.invalidate('ui.render')
207 }
208 return null
209 }
210 if (isSession) {
211 // a write moved the ref past what origin has
212 if (done.written && sync === 'synced') sync = 'ahead'
213 adopt($, done.items, done.written)
214 }
215 return done.result
216}
217
218// The session repo's items, as its ref holds them after a write or a move; `moved` says whether the ref changed.
219function adopt($, list, moved) {
220 fileError = null
221 items = list
222 if (moved && seenReady) background($, recordSeen($, sessionRepo, { compare: false }), 'record what this session sees of the repo')
223 if (editing && !items.some(i => i.id === editing)) editing = null
224 $.ui.invalidate('ui.render')
225}
226
227async function mutate($, fn, root, report) {
228 const run = writeChain.then(() => mutateNow($, fn, root, report))
229 writeChain = run.catch(() => {})
230 return run
231}
232
233async function toplevelOf($, dir) {
234 const r = await $.process.run(['git', '-C', dir, 'rev-parse', '--show-toplevel'])
235 if (r.exitCode !== 0) return null
236 const top = typeof r.stdout === 'string' ? r.stdout.trim() : ''
237 if (!isAbsolutePath(top)) throw new Error('git did not give the repo root')
238 return normalizePath(top)
239}
240
241// Toplevel of the git repo a path belongs to, or null (also when git cannot run). A file path starts at its
242// directory; anything else starts at itself. Climbs a few levels for folders that do not exist yet.
243async function repoOf($, path, { file = false, fresh = false } = {}) {
244 if (!isAbsolutePath(path)) return null
245 let dir = normalizePath(path)
246 if (file) dir = parentPath(dir)
247 const tried = []
248 for (let depth = 0; depth < MAX_WALK; depth++) {
249 if (fresh) repoCache.delete(dir)
250 let top = repoCache.get(dir)
251 if (top === undefined) {
252 try {
253 top = await toplevelOf($, dir)
254 } catch {
255 return null
256 }
257 if (repoCache.size >= MAX_CACHE) repoCache.clear()
258 repoCache.set(dir, top)
259 }
260 if (top) {
261 for (const d of tried) repoCache.set(d, top)
262 return isIgnoredRepo(top, await homeDir($)) ? null : top
263 }
264 tried.push(dir)
265 const parent = parentPath(dir)
266 if (parent === dir) return null
267 dir = parent
268 }
269 return null
270}
271
272async function homeDir($) {
273 if (homePath === undefined) {
274 try {
275 const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
276 homePath = home ? normalizePath(home) : null
277 } catch {
278 homePath = null
279 }
280 }
281 return homePath
282}
283
284// The session's repo from its root, read afresh; throws when git cannot say, so callers keep the last answer.
285// A repo under `.claude` or the home directory counts as no repo.
286async function resolveSessionRepo($) {
287 const session = await $.session.root()
288 const root = isAbsolutePath(session) ? normalizePath(session) : session
289 repoCache.delete(root)
290 const top = await toplevelOf($, root)
291 repoCache.set(root, top)
292 const before = sessionRepo
293 sessionRepo = top && !isIgnoredRepo(top, await homeDir($)) ? top : null
294 if (sessionRepo !== before) {
295 // nothing of the old repo may be drawn while the new one loads
296 items = []
297 fileError = null
298 editing = null
299 justClosed = null
300 notice = null
301 sync = null
302 triage.clear()
303 $.ui.invalidate('ui.render')
304 }
305}
306
307// On the write chain, so a write never straddles a change of session repo.
308async function refreshSessionRepo($) {
309 const run = writeChain.then(() => resolveSessionRepo($))
310 writeChain = run.catch(() => {})
311 return run
312}
313
314async function branchOf($, dir) {
315 const r = await $.process.run(['git', '-C', dir, 'rev-parse', '--abbrev-ref', 'HEAD'])
316 return r.exitCode === 0 ? r.stdout.trim() : null
317}
318
319async function readBranch($) {
320 return sessionRepo ? branchOf($, sessionRepo) : null
321}
322
323async function readSha($, root, ref) {
324 const r = await $.process.run(['git', '-C', root, ...shaArgs(ref)])
325 return r.exitCode === 0 ? firstLine(r.stdout) : null
326}
327
328async function hasRemote($, root) {
329 const r = await $.process.run(['git', '-C', root, ...REMOTE_ARGS])
330 return r.exitCode === 0 && hasOrigin(r.stdout)
331}
332
333// Where the ref stands against origin, from the local ref and the copy of origin's that the last fetch left.
334async function refreshSync($, root) {
335 if (!(await hasRemote($, root))) return 'local'
336 const mine = await readSha($, root, REF)
337 const theirs = await readSha($, root, REMOTE_REF)
338 return !mine || mine === theirs ? 'synced' : 'ahead'
339}
340
341// On the write chain: brings origin's items (`theirs`, read from its copy) into the local ref. When the local ref
342// holds nothing origin lacks, it moves to origin's commit, so both sides end on the same commit and read «Al día»;
343// otherwise, or when another session moved it in between, the merge is written as a new commit.
344// Answers whether the local ref now holds origin's items.
345async function mergeRemoteNow($, root, theirs) {
346 const local = await readRef($, root)
347 if (local.ok && theirs.sha && sameItems(mergeItems(local.items, theirs.items), theirs.items)) {
348 if (local.sha === theirs.sha) return true
349 const moved = await $.process.run(['git', '-C', root, ...updateArgs(theirs.sha, local.sha)])
350 if (moved.exitCode === 0) {
351 if (root === sessionRepo) adopt($, theirs.items, true)
352 return true
353 }
354 }
355 const report = {}
356 await mutateNow($, list => mergeItems(list, theirs.items), root, report)
357 return !report.error
358}
359
360// Brings origin's loose ends into the local ref. Answers null without origin; otherwise whether the merge went well
361// (`ok`) and the sha fetched (`lease`, for a push), null when origin has no ref yet or the fetch failed.
362async function pullRemote($, root) {
363 if (!(await hasRemote($, root))) return null
364 let fetched
365 try {
366 fetched = await $.process.run(['git', '-C', root, ...FETCH_ARGS], { timeoutMs: 20000, env: NET_ENV })
367 } catch (err) {
368 // a fetch that outlasts its timeout rejects; it counts as one that failed
369 await logDebug($, `loose-ends: could not fetch the loose ends from origin (${err?.message ?? err})`)
370 return { ok: true, lease: null }
371 }
372 if (fetched.exitCode !== 0) {
373 await logDebug($, 'loose-ends: could not fetch the loose ends from origin')
374 return { ok: true, lease: null }
375 }
376 const theirs = await readRef($, root, REMOTE_REF)
377 if (!theirs.ok) {
378 await logDebug($, `loose-ends: the loose ends on origin cannot be read (${theirs.error})`)
379 return { ok: false, lease: null }
380 }
381 const run = writeChain.then(() => mergeRemoteNow($, root, theirs))
382 writeChain = run.catch(() => {})
383 let ok = false
384 try {
385 ok = await run
386 } catch (err) {
387 await logDebug($, `loose-ends: merging the loose ends from origin failed (${err?.message ?? err})`)
388 }
389 return { ok, lease: theirs.sha }
390}
391
392// Merges origin first, then pushes the commit the local ref holds, refused if origin moved after the fetch. A merge
393// that failed pushes nothing: the local ref may lack what origin has.
394async function pushRemote($, root) {
395 if (!root) return
396 const pulled = await pullRemote($, root)
397 if (pulled && !pulled.ok) {
398 if (root === sessionRepo) {
399 sync = 'failed'
400 $.ui.invalidate('ui.render')
401 }
402 await logDebug($, 'loose-ends: not pushing the loose ends because they could not be merged with the ones on origin')
403 return
404 }
405 const lease = pulled?.lease ?? null
406 const mine = await readSha($, root, REF)
407 if (!mine) return
408 if (mine !== lease) {
409 let pushed
410 let why = ''
411 try {
412 pushed = await $.process.run(['git', '-C', root, ...pushArgs(lease, mine)], { timeoutMs: 30000, env: NET_ENV })
413 } catch (err) {
414 // a push that outlasts its timeout rejects; it counts as one that failed
415 why = ` (${err?.message ?? err})`
416 }
417 if (!pushed || pushed.exitCode !== 0) {
418 if (root === sessionRepo) {
419 sync = 'failed'
420 $.ui.invalidate('ui.render')
421 }
422 await logDebug($, `loose-ends: could not push the loose ends to origin${why}`)
423 return
424 }
425 await $.process.run(['git', '-C', root, ...trackArgs(mine)])
426 }
427 if (root === sessionRepo) {
428 // a write that landed after `mine` was read is still to push
429 sync = await refreshSync($, root)
430 $.ui.invalidate('ui.render')
431 }
432}
433
434// After the person's own git push: pushes the ref too, asking the first time (Siempre and Nunca are remembered).
435async function afterUserPush($) {
436 const root = sessionRepo
437 if (!root) return
438 if ((await refreshSync($, root)) !== 'ahead') return
439 const pref = await $.process.run(['git', '-C', root, ...SYNC_GET_ARGS])
440 const mode = pref.exitCode === 0 ? firstLine(pref.stdout) : null
441 if (mode === 'false') return
442 if (mode !== 'true') {
443 const always = t(lang, 'push.always')
444 const once = t(lang, 'push.once')
445 const never = t(lang, 'push.never')
446 const answer = await $.ui.ask(t(lang, 'push.question'), { options: [always, once, never], header: t(lang, 'push.header') })
447 if (answer === never) {
448 await $.process.run(['git', '-C', root, ...syncSetArgs(false)])
449 return
450 }
451 if (answer === always) await $.process.run(['git', '-C', root, ...syncSetArgs(true)])
452 else if (answer !== once) return
453 }
454 await pushRemote($, root)
455}
456
457// Imports the items a 0.3 version left in <root>/.claude/loose-ends.json whose ids the ref lacks; the file stays.
458async function importLegacy($, root) {
459 const path = `${root}/${LEGACY_FILE}`
460 if (!(await $.fs.exists(path))) return
461 const parsed = parseItems(await $.fs.read(path))
462 if (!parsed.ok || !parsed.items.length) return
463 const res = await mutate($, list => {
464 const known = new Set(list.map(i => i.id))
465 const fresh = parsed.items.filter(i => !known.has(i.id))
466 return { items: fresh.length ? [...list, ...fresh] : list, added: fresh.length }
467 }, root)
468 // only an import that was written may tell the person to delete the file
469 if (res?.added && root === sessionRepo) notice = tn(lang, 'notice.imported', res.added, { file: LEGACY_FILE })
470}
471
472// At session start: the 0.3 file, then origin, then where the ref stands; a failing step does not skip the next.
473async function startSync($, root) {
474 await guarded($, 'import .claude/loose-ends.json', () => importLegacy($, root))
475 await guarded($, 'fetch the loose ends from origin', () => pullRemote($, root))
476 await guarded($, 'check for unpushed loose ends', async () => {
477 if (root !== sessionRepo) return
478 sync = await refreshSync($, root)
479 $.ui.invalidate('ui.render')
480 })
481 if (root === sessionRepo) await guarded($, 'compare with what the last session saw', () => recordSeen($, root, { compare: true }))
482}
483
484async function seenPath($, root) {
485 const r = await $.process.run(['git', '-C', root, ...COMMON_DIR_ARGS])
486 const dir = r.exitCode === 0 ? firstLine(r.stdout) : null
487 return dir ? `${normalizePath(dir)}/${SEEN_FILE}` : null
488}
489
490// Records what this session sees of the repo; with `compare`, first sets the band's «Desde …» against what the
491// last session saw.
492async function recordSeen($, root, { compare }) {
493 if (fileError) return
494 const path = await seenPath($, root)
495 if (!path) return
496 const now = await $.clock.now()
497 if (compare) {
498 if (await $.fs.exists(path)) {
499 let seen = null
500 try {
501 seen = JSON.parse(await $.fs.read(path))
502 } catch {}
503 const r = recap(items, seen)
504 if (!firstTurnDone) recapNow = r && (r.fresh || r.closed) ? { since: sinceText(lang, r.at, now), fresh: r.fresh, closed: r.closed } : null
505 $.ui.invalidate('ui.render')
506 }
507 seenReady = true
508 } else if (!seenReady) return
509 await $.fs.write(path, JSON.stringify(snapshot(items, new Date(now).toISOString())))
510}
511
512// The open loose ends anchored to the file a main-loop tool reads or edits, as a line for Claude; once per file
513// and turn, and only for the session repo, whose items live in memory.
514function passingFor(e) {
515 if (!sessionRepo || !FILE_TOOLS.has(e.tool)) return null
516 const path = e.tool === 'NotebookEdit' ? e.notebook_path : e.file_path
517 const rel = isAbsolutePath(path) ? relativeTo(sessionRepo, path) : undefined
518 if (!rel || passed.has(rel)) return null
519 const here = live(items).filter(i => i.file === rel)
520 if (!here.length) return null
521 return { rel, text: passingText(lang, here) }
522}
523
524// Runs the filter and files a candidate in `target`. `reason` says why the filter refused it, `error` why the
525// repo could not be read or written.
526async function offer($, input, target) {
527 const now = await nowIso($)
528 const noteBranch = await branchOf($, target)
529 const id = crypto.randomUUID().replace(/-/g, '').slice(0, 8)
530 const report = {}
531 let reason = null
532 const res = await mutate($, (list, isSession) => {
533 if (isSession) branch = noteBranch
534 reason = rejectReason(input, list)
535 return reason ? { items: list, added: null } : propose(list, { ...input, id, branch: noteBranch, now })
536 }, target, report)
537 if (report.error) return { error: report.error }
538 if (reason) {
539 await logDebug($, `loose-ends: candidate rejected (${rejectText(lang, reason)}): ${input.text}`)
540 return { reason }
541 }
542 return res?.added ? { added: res.added } : { reason: 'tooShort' }
543}
544
545// The last commit of the session repo made since `since` (ISO), or null when there is none, no repo, or git fails.
546async function commitSince($, since) {
547 if (!sessionRepo || !since) return null
548 const r = await $.process.run(['git', '-C', sessionRepo, ...lastCommitArgs(since)])
549 return r.exitCode === 0 ? firstLine(r.stdout) : null
550}
551
552// The commits of the session repo made since `since`, as «sha subject» lines; empty when there are none or git fails.
553async function logSince($, since) {
554 if (!sessionRepo || !since) return ''
555 const r = await $.process.run(['git', '-C', sessionRepo, ...turnLogArgs(since)])
556 return r.exitCode === 0 && typeof r.stdout === 'string' ? r.stdout.trim() : ''
557}
558
559// Repos the main loop touched this turn, once the lookups queued by `trackRepos` have finished.
560async function collectTouched($, set, paths, isFile) {
561 for (const path of paths) {
562 const top = await repoOf($, path, { file: isFile })
563 if (top && set.size < MAX_TOUCHED) set.add(top)
564 }
565}
566
567function trackRepos($, e) {
568 const paths = candidatePaths(e.tool, e).slice(0, MAX_PATHS_PER_CALL)
569 if (!paths.length) return
570 const set = touched
571 const isFile = FILE_TOOLS.has(e.tool)
572 touchChain = touchChain.then(() => collectTouched($, set, paths, isFile)).catch(err => logDebug($, `loose-ends: resolving the touched repos failed (${err?.message ?? err})`))
573}
574
575// Haiku reads the answer: new candidates only from long answers and only when Claude noted nothing by hand; in any
576// answer, closures for the live items and the candidates still waiting, which a later answer often shows done. What
577// the sweep proposes is held back one turn: the next sweep withdraws it if it was done meanwhile, or shows it.
578async function sweep($, answer, touchedNow, commit, { allowNew, log = '' }) {
579 const held = heldCandidates(items).map(i => i.id)
580 const resolvedIds = new Set()
581 try {
582 await sweepAnswer($, answer, touchedNow, commit, { allowNew, log }, resolvedIds)
583 } finally {
584 await releaseHeld($, held.filter(id => !resolvedIds.has(id)))
585 }
586}
587
588// The held candidates go to the person, unless a subagent still runs and they are younger than HOLD_MAX_MS.
589async function releaseHeld($, ids) {
590 if (!ids.length || !sessionRepo) return
591 const nowMs = await $.clock.now()
592 const ready = ids.filter(id => {
593 const item = items.find(i => i.id === id)
594 return item && (!runningAgents.size || nowMs - (Date.parse(item.createdAt) || 0) >= HOLD_MAX_MS)
595 })
596 if (!ready.length) return
597 const now = await nowIso($)
598 await guarded($, 'show the sweep candidates', () => mutate($, list => release(list, ready, now)))
599}
600
601async function sweepAnswer($, answer, touchedNow, commit, { allowNew, log }, resolvedIds) {
602 const others = (await touchedNow).filter(repo => repo !== sessionRepo)
603 if (!sessionRepo && !others.length) return
604 const repos = others.length ? candidateRepos(sessionRepo, others) : []
605 const open = live(items)
606 const waiting = [...candidates(items), ...heldCandidates(items)]
607 const r = await $.model.complete({
608 model: SWEEP_MODEL,
609 system: sweepSystem(lang),
610 prompt: buildSweepPrompt(lang, answer, open, waiting, repos, rejectedTexts(items), log),
611 maxTokens: 800,
612 timeoutMs: 20000,
613 })
614 if (!r.isAnswered) {
615 await logDebug($, `loose-ends: sweep skipped (${r.reason})`)
616 return
617 }
618 const parsed = parseSweepReply(r.text, [...open, ...waiting].map(i => i.id), repos, answer, { allowNew, log })
619 if (!parsed) {
620 await logDebug($, 'loose-ends: the sweep returned invalid JSON')
621 return
622 }
623 if (parsed.dropped) await logDebug($, `loose-ends: ${parsed.dropped} ${parsed.dropped === 1 ? 'proposal' : 'proposals'} dropped for a quote that is not literal`)
624 for (const { text, reason } of parsed.skipped) await logDebug($, `loose-ends: sweep candidate dropped (${reason}): ${text}`)
625 for (const { repo, ...fresh } of parsed.fresh) {
626 const target = repo ?? sessionRepo
627 if (!target) continue
628 await guarded($, `propose in ${target}`, async () => {
629 const out = await offer($, { ...fresh, source: 'sweep', held: true }, target)
630 if (out.error) await logDebug($, `loose-ends: could not propose in ${target} (${out.error})`)
631 })
632 }
633 if (parsed.resolved.length && sessionRepo) {
634 const now = await nowIso($)
635 await guarded($, 'propose the closures', async () => {
636 const res = await mutate($, list => {
637 // a live item gets a closure for the person to confirm; a candidate nobody accepted yet goes away by itself
638 const next = parsed.resolved.reduce((acc, r) => withdraw(proposeClose(acc, r.id, { quote: r.quote, commit }, now), r.id, { quote: r.quote, commit }, now), list)
639 const changed = parsed.resolved.map(r => ({ before: list.find(i => i.id === r.id), after: next.find(i => i.id === r.id) })).filter(c => c.before && c.after !== c.before)
640 return { items: next, changed }
641 })
642 for (const { before, after } of res?.changed ?? []) {
643 resolvedIds.add(before.id)
644 // a card's Deshacer would put back the item without the closure just proposed
645 triage.delete(before.id)
646 // the withdrawn candidate's card says so under its message, and Deshacer brings it back to review; a held one
647 // was never shown, so it goes without a word
648 if (before.status === 'candidate' && after.status !== 'candidate' && before.held) {
649 await logDebug($, `loose-ends: held candidate withdrawn, resolved later: ${before.text}`)
650 } else if (before.status === 'candidate' && after.status !== 'candidate' && before.evidence) {
651 triage.set(before.id, { kind: 'candidate', quote: before.evidence, state: 'withdrawn', previous: before })
652 await logDebug($, `loose-ends: candidate withdrawn, resolved later: ${before.text}`)
653 }
654 }
655 })
656 }
657}
658
659function bandModel(now) {
660 const c = counts(items)
661 return {
662 lang,
663 mood: chispaMood({ candidates: c.candidates, urgent: c.high, flashUntil, working, lastActivity, now }),
664 counts: c,
665 urgent: topUrgent(items),
666 justClosed: justClosed && now < justClosed.until ? justClosed : null,
667 recap: recapNow,
668 fileError,
669 next: nextItem(items),
670 sync,
671 working,
672 closedWeek: closedRecently(items, now).length,
673 ...localClock(now),
674 }
675}
676
677function paneModel(now) {
678 return {
679 now,
680 lang,
681 branch,
682 working,
683 fileError,
684 noRepo: sessionRepo === null,
685 repoName: sessionRepo ? repoName(sessionRepo) : '',
686 repoPath: sessionRepo ?? '',
687 notice,
688 sync,
689 editing,
690 waiting: candidates(items),
691 live: live(items).map(i => ({ ...i, stale: isStale(i, now) })),
692 closed: closedRecently(items, now),
693 learned: items.filter(i => i.status === 'rejected').length,
694 }
695}
696
697// The cards a message of the assistant carries: the candidates and proposed closures it quotes, and the ones the
698// person already answered there.
699function triageCards(text) {
700 if (!sessionRepo || typeof text !== 'string') return []
701 const cards = []
702 for (const item of items) {
703 const answered = triage.get(item.id)
704 if (answered && containsQuote(text, answered.quote)) {
705 cards.push({ kind: answered.kind, item, quote: answered.quote, state: answered.state })
706 continue
707 }
708 if (item.status === 'candidate' && !item.held && item.evidence && containsQuote(text, item.evidence)) cards.push({ kind: 'candidate', item, quote: item.evidence, state: null })
709 else if (item.proposal && containsQuote(text, item.proposal.quote)) cards.push({ kind: 'proposal', item, quote: item.proposal.quote, state: null })
710 }
711 return cards.map(c => ({ ...c, lang, repoName: repoName(sessionRepo), editing: editing === c.item.id }))
712}
713
714// One change the person makes to an item. With `state`, the card under its message remembers the answer and how
715// the item was before, so Deshacer can put it back. Answers the item as it was, or null when nothing changed.
716async function act($, id, change, state) {
717 const before = items.find(i => i.id === id)
718 if (!before) return null
719 lastActivity = await $.clock.now()
720 const now = await nowIso($)
721 // transitions keep the items they do not touch as the same objects, so a changed one is a different object
722 const res = await mutate($, list => {
723 const next = change(list, id, now)
724 return { items: next, changed: next.find(i => i.id === id) !== list.find(i => i.id === id) }
725 })
726 if (!res?.changed) return null
727 // a later action from the pane replaces the answer the card remembered
728 triage.delete(id)
729 if (state) {
730 const closing = state === 'closed' || state === 'kept'
731 const quote = closing ? before.proposal?.quote : before.evidence
732 if (quote) triage.set(id, { kind: closing ? 'proposal' : 'candidate', quote, state, previous: before })
733 }
734 return before
735}
736
737// A close: the band says so for 4 s with Deshacer, and Chispa celebrates.
738async function closeWith($, id, change, state) {
739 const before = await act($, id, change, state)
740 if (!before || items.find(i => i.id === id)?.status !== 'done') return
741 const now = await $.clock.now()
742 justClosed = { item: before, text: before.text, until: now + FLASH_MS }
743 flashUntil = now + FLASH_MS
744 $.ui.invalidate('ui.render')
745 $.clock.after(FLASH_MS + 50, () => $.ui.invalidate('ui.render'))
746}
747
748// The card's Deshacer: the item goes back to how it was before the person answered the card.
749async function undo($, id) {
750 const previous = triage.get(id)?.previous
751 if (!previous) return
752 triage.delete(id)
753 if (justClosed?.item.id === id) justClosed = null
754 const now = await nowIso($)
755 await mutate($, list => restore(list, previous, now))
756}
757
758// The band's Deshacer: the item goes back to how it was before the last close, whatever happened to it since.
759async function undoClosed($) {
760 const previous = justClosed?.item
761 if (!previous) return
762 justClosed = null
763 triage.delete(previous.id)
764 const now = await nowIso($)
765 await mutate($, list => restore(list, previous, now))
766}
767
768// Hacer, from the pane or the band: starts the item in the background and logs a failure.
769function pressNow($, id) {
770 background($, doNow($, id), 'start the loose end')
771}
772
773async function doNow($, id) {
774 const item = items.find(i => i.id === id)
775 if (!item) return
776 if (working) {
777 queuedNow = id
778 await $.ui.toast(t(lang, 'toast.queued', { text: item.text }))
779 return
780 }
781 const now = await nowIso($)
782 const res = await mutate($, list => start(list, id, now))
783 if (res === null) return
784 // a card's Deshacer would put back the item as it was before Hacer
785 triage.delete(id)
786 await $.prompt.submit({ text: doNowText(lang, item) })
787}
788
789// The urgent item as the prompt's suggestion, sent once the turn has ended: while a turn runs the box shows none.
790async function suggestUrgent($) {
791 const item = suggestable(items)
792 if (!item || working) return
793 const r = await $.prompt.suggest({ text: suggestText(lang, item) })
794 if (!r?.isShown) await logDebug($, 'loose-ends: the suggestion of the urgent loose end was not shown')
795}
796
797async function addByHand($, text) {
798 if (!sessionRepo) return
799 const now = await nowIso($)
800 const id = crypto.randomUUID().replace(/-/g, '').slice(0, 8)
801 await mutate($, list => addManual(list, { text, id, branch, now }))
802}
803
804// What the card and the pane can do; every write runs in the background and logs its failure.
805function itemActions($) {
806 return {
807 save: id => background($, act($, id, save, 'saved'), 'save the loose end'),
808 reject: id => background($, act($, id, reject, 'rejected'), 'reject the candidate'),
809 confirm: id => background($, closeWith($, id, confirmClose, 'closed'), 'close the loose end'),
810 keep: id => background($, act($, id, keepOpen, 'kept'), 'keep the loose end open'),
811 undo: id => background($, undo($, id), 'undo'),
812 startEdit: id => {
813 editing = editing === id ? null : id
814 $.ui.invalidate('ui.render')
815 },
816 saveEdited: (id, text) => {
817 editing = null
818 background($, act($, id, (list, target, now) => editText(list, target, text, now)), 'edit the loose end')
819 },
820 doNow: id => pressNow($, id),
821 done: id => background($, closeWith($, id, markDone, null), 'close the loose end'),
822 dismiss: id => background($, act($, id, dismiss), 'dismiss the loose end'),
823 // ↺ reopens a closed item, or brings a withdrawn candidate back to review
824 reopen: id => background($, act($, id, (list, target, now) => unwithdraw(reopen(list, target, now), target, now)), 'reopen the loose end'),
825 cyclePriority: id =>
826 background($, act($, id, (list, target, now) => setPriority(list, target, NEXT_PRIORITY[list.find(i => i.id === target)?.priority] ?? 'medium', now)), 'change the priority'),
827 keepFresh: id => background($, act($, id, touch), 'keep the loose end fresh'),
828 add: text => background($, addByHand($, text), 'note a loose end'),
829 push: () => background($, pushRemote($, sessionRepo), 'push the loose ends'),
830 dismissNotice: () => {
831 notice = null
832 $.ui.invalidate('ui.render')
833 },
834 }
835}
836
837// Opens the Cuaderno pane with its title in the mod's language.
838async function openNotebook($) {
839 await resolveLanguage($)
840 await $.ui.open({ id: 'loose-ends', title: t(lang, 'pane.title') })
841}
842
843export function register(on, options = {}) {
844 languageOption = options.language ?? 'auto'
845 languageReady = false
846 on('session.start', async ($, e, next) => {
847 await resolveLanguage($)
848 editing = null
849 recapNow = null
850 seenReady = false
851 firstTurnDone = false
852 passed.clear()
853 justClosed = null
854 triage.clear()
855 working = false
856 flashUntil = 0
857 turnStartedAt = null
858 runningAgents.clear()
859 notedThisTurn = false
860 lastActivity = await $.clock.now()
861 await $.tool.register({ name: TOOL_NAME, description: toolDescription(lang), inputSchema: toolSchema(lang) })
862 await $.command.register({ name: t(lang, 'command.name'), description: t(lang, 'command.description'), immediate: true })
863 touched = new Set()
864 sessionRepo = null
865 await guarded($, 'resolve the session repo', () => refreshSessionRepo($))
866 branch = null
867 await guarded($, 'read the branch', async () => {
868 branch = await readBranch($)
869 })
870 await guarded($, 'read the loose ends', () => load($))
871 // what an earlier session's sweep held back has had its turn: it goes to the person now
872 await releaseHeld($, heldCandidates(items).map(i => i.id))
873 notice = null
874 sync = null
875 if (sessionRepo) background($, startSync($, sessionRepo), 'fetch the loose ends from origin')
876 if (refreshTimer) refreshTimer.cancel()
877 refreshTimer = $.clock.every(60000, () => $.ui.invalidate('ui.render'))
878 return next(e)
879 })
880
881 on('tool.call', async ($, e, next) => {
882 if (e.agentId) return next(e)
883 await resolveLanguage($)
884 lastActivity = await $.clock.now()
885 const r = await next(e)
886 trackRepos($, e)
887 if (e.tool === 'Bash' && typeof e.command === 'string') {
888 const failed = bashFailed(r)
889 if (isCommit(e.command, failed)) {
890 flashUntil = (await $.clock.now()) + FLASH_MS
891 $.ui.invalidate('ui.render')
892 $.clock.after(FLASH_MS + 50, () => $.ui.invalidate('ui.render'))
893 }
894 if (isPush(e.command, failed)) background($, afterUserPush($), 'push the loose ends after your push')
895 }
896 const passing = passingFor(e)
897 if (passing && r && !r.deny && !r.isError) {
898 passed.add(passing.rel)
899 return { ...r, context: [...(r.context ?? []), passing.text] }
900 }
901 return r
902 })
903
904 on('turn.start', async ($, e, next) => {
905 await resolveLanguage($)
906 working = true
907 if (e.agentId) runningAgents.add(e.agentId)
908 else notedThisTurn = false
909 if (!e.agentId) turnStartedAt = await nowIso($)
910 lastActivity = await $.clock.now()
911 touched = new Set()
912 if (!e.agentId) {
913 passed.clear()
914 const prefix = suggestPrefix(lang)
915 if (typeof e.text === 'string' && e.text.startsWith(prefix)) {
916 const wanted = e.text.slice(prefix.length).trim()
917 const item = live(items).find(i => i.text === wanted)
918 if (item) background($, act($, item.id, start), 'start the suggested loose end')
919 }
920 }
921 for (const [dir, top] of repoCache) if (top === null) repoCache.delete(dir)
922 $.ui.invalidate('ui.render')
923 return next(e)
924 })
925
926 on('turn.complete', async ($, e, next) => {
927 await resolveLanguage($)
928 const r = await next(e)
929 if (e.agentId) {
930 runningAgents.delete(e.agentId)
931 return r
932 }
933 working = false
934 recapNow = null
935 firstTurnDone = true
936 lastActivity = await $.clock.now()
937 $.ui.invalidate('ui.render')
938 await guarded($, 'resolve the session repo after the turn', () => refreshSessionRepo($))
939 await guarded($, 'read the branch after the turn', async () => {
940 branch = await readBranch($)
941 })
942 const now = await nowIso($)
943 await guarded($, 'refresh the loose ends after the turn', () => mutate($, list => expireCandidates(list, now)))
944 if (sessionRepo) await guarded($, 'record what this session sees of the repo', () => recordSeen($, sessionRepo, { compare: false }))
945 if (queuedNow) {
946 const id = queuedNow
947 queuedNow = null
948 $.clock.after(500, () => pressNow($, id))
949 } else if (suggestable(items)) $.clock.after(500, () => background($, suggestUrgent($), 'suggest the urgent loose end'))
950 if (e.reason === 'answer' && typeof e.answer === 'string' && e.answer.trim()) {
951 const allowNew = shouldSweep(e.answer) && !notedThisTurn
952 if (allowNew || live(items).some(i => i.status === 'doing') || items.some(i => i.status === 'candidate')) {
953 const set = touched
954 let commit = null
955 let log = ''
956 await guarded($, 'read the commit of the turn', async () => {
957 commit = await commitSince($, turnStartedAt)
958 if (commit) log = await logSince($, turnStartedAt)
959 })
960 background($, sweep($, e.answer, touchChain.then(() => [...set]), commit, { allowNew, log }), 'the sweep')
961 }
962 }
963 return r
964 })
965
966 on('tool.call', { tool: 'mcp__loose-ends__note_loose_end' }, async ($, e) => {
967 await resolveLanguage($)
968 notedThisTurn = true
969 if (String(e.text ?? '').trim().length < 3) return { result: toolTooShort(lang) }
970 let target = sessionRepo
971 if (typeof e.repo === 'string' && e.repo.trim()) {
972 target = await repoOf($, e.repo.trim(), { fresh: true })
973 if (!target) return { result: notARepo(lang) }
974 } else if (!target) return { result: noGitNote(lang) }
975 let out
976 try {
977 out = await offer($, { text: e.text, category: canonicalCategory(e.category), priority: e.priority, evidence: e.evidence, file: relativeTo(target, e.file), source: 'tool' }, target)
978 } catch (err) {
979 return { result: toolUnreadable(lang, target, err?.message ?? String(err)) }
980 }
981 if (out.error) return { result: toolUnreadable(lang, target, out.error) }
982 if (out.reason) return { result: toolRejected(lang, rejectText(lang, out.reason)) }
983 return { result: toolProposed(lang, out.added) }
984 })
985
986 on('prompt.suggest', async ($, e, next) => {
987 await resolveLanguage($)
988 if (e.origin?.kind !== 'suggestion') return next(e)
989 const item = suggestable(items)
990 return item ? next({ ...e, text: suggestText(lang, item) }) : next(e)
991 })
992
993 on('prompt.compose', async ($, e, next) => {
994 await resolveLanguage($)
995 const r = await next(e)
996 return { sections: [...r.sections, { id: 'loose-ends:guide', text: toolGuide(lang), scope: 'session' }] }
997 })
998
999 on('prompt.context', async ($, e, next) => {
1000 await resolveLanguage($)
1001 const r = await next(e)
1002 await load($)
1003 const text = formatContext(lang, live(items))
1004 return text ? { ...r, blocks: [...r.blocks, { name: 'looseEnds', text }] } : r
1005 })
1006
1007 // the command is /pendientes in Spanish and /loose-ends in English; only the one of the language is registered
1008 on('command.run', { command: 'pendientes' }, async ($) => {
1009 await openNotebook($)
1010 return {}
1011 })
1012
1013 on('command.run', { command: 'loose-ends' }, async ($) => {
1014 await openNotebook($)
1015 return {}
1016 })
1017
1018 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1019 await resolveLanguage($)
1020 if (e.props.hasSurvey) return next(e)
1021 const el = $.ui.resolve(e)
1022 const now = await $.clock.now()
1023 return renderBand(el, e.surface, bandModel(now), {
1024 openPane: () => {
1025 background($, openNotebook($), 'open the notebook')
1026 },
1027 undoClose: () => {
1028 background($, undoClosed($), 'undo the close')
1029 },
1030 doNow: id => pressNow($, id),
1031 })
1032 })
1033
1034 on('ui.render', { component: 'Pane', requestId: 'loose-ends' }, async ($, e) => {
1035 await resolveLanguage($)
1036 const el = $.ui.resolve(e)
1037 const now = await $.clock.now()
1038 return renderPane(el, e.surface, paneModel(now), itemActions($))
1039 })
1040
1041 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
1042 await resolveLanguage($)
1043 const cards = triageCards(e.props.text)
1044 if (!cards.length) return next(e)
1045 const text = cards.reduce((t, c) => emphasize(t, c.quote), e.props.text)
1046 const drawn = await next({ ...e, props: { ...e.props, text } })
1047 const el = $.ui.resolve(e)
1048 const actions = itemActions($)
1049 return el.Box({ flexDirection: 'column', gap: 1, children: [drawn, ...cards.map(c => renderTriage(el, e.surface, c, actions))] })
1050 })
1051}
1052lib/refstore.mjs 73 lines1// Where a repo keeps its loose ends: the ref refs/loose-ends/items, outside every branch, whose commit holds one
2// blob. Pure: argv builders, parsers and the merge; register.mjs runs the commands.
3// Two components under refs/, because a remote refuses a ref with one ("funny refname").
4export const REF = 'refs/loose-ends/items'
5// Origin's copy, as the last fetch or push left it; outside refs/remotes, so `git branch -r` and `fetch --prune`
6// never see it and a branch called loose-ends never clashes with it.
7export const REMOTE_REF = 'refs/loose-ends/origin'
8export const BLOB = 'loose-ends.json'
9// commit-tree needs an identity, and a repo without user.name or user.email must still work
10export const GIT_ENV = { GIT_AUTHOR_NAME: 'loose-ends', GIT_AUTHOR_EMAIL: 'loose-ends@localhost', GIT_COMMITTER_NAME: 'loose-ends', GIT_COMMITTER_EMAIL: 'loose-ends@localhost' }
11// fetch and push fail at once instead of waiting for a credential prompt nobody sees
12export const NET_ENV = { GIT_TERMINAL_PROMPT: '0' }
13
14export const shaArgs = (ref = REF) => ['rev-parse', '--verify', '--quiet', ref]
15export const blobArgs = sha => ['cat-file', 'blob', `${sha}:${BLOB}`]
16export const HASH_ARGS = ['hash-object', '-w', '--stdin']
17export const TREE_ARGS = ['mktree']
18export const treeInput = blob => `100644 blob ${blob}\t${BLOB}\n`
19export const commitArgs = (tree, parent) => ['commit-tree', tree, ...(parent ? ['-p', parent] : []), '-m', 'loose-ends']
20// The old value makes the move fail when another session moved the ref first; empty means "must not exist yet"
21// (40 zeros would be refused by a SHA-256 repo).
22export const updateArgs = (next, prev) => ['update-ref', REF, next, prev ?? '']
23export const REMOTE_ARGS = ['remote']
24export const FETCH_ARGS = ['fetch', '--quiet', 'origin', `+${REF}:${REMOTE_REF}`]
25// Sends commit `sha` (the one just read) to origin's ref, only if origin still holds what was fetched (nothing,
26// when lease is null). `--no-verify`: the repo's pre-push hooks (tests, lint) guard its branches, not this ref, and
27// running them in the background could outlast the timeout.
28export const pushArgs = (lease, sha) => ['push', '--quiet', '--no-verify', `--force-with-lease=${REF}:${lease ?? ''}`, 'origin', `${sha}:${REF}`]
29export const trackArgs = sha => ['update-ref', REMOTE_REF, sha]
30export const SYNC_GET_ARGS = ['config', '--get', 'loose-ends.sync']
31export const syncSetArgs = on => ['config', 'loose-ends.sync', on ? 'true' : 'false']
32export const COMMON_DIR_ARGS = ['rev-parse', '--path-format=absolute', '--git-common-dir']
33// The last commit of HEAD made since `since` (an ISO timestamp); prints nothing when there is none.
34export const lastCommitArgs = since => ['log', '-1', `--since=${since}`, '--format=%h']
35// The commits of a turn, newest first, as «sha subject»: the sweep may quote one as the proof of a closure.
36export const turnLogArgs = since => ['log', '-5', `--since=${since}`, '--format=%h %s']
37
38// First line of a git answer, trimmed; null when there is none.
39export function firstLine(stdout) {
40 const line = typeof stdout === 'string' ? stdout.split('\n')[0].trim() : ''
41 return line || null
42}
43
44export function hasOrigin(stdout) {
45 return typeof stdout === 'string' && stdout.split('\n').some(line => line.trim() === 'origin')
46}
47
48const stamp = item => Date.parse(item.updatedAt ?? item.createdAt ?? '') || 0
49
50// Union by id: mine first, then what only theirs has. When both hold an item the later updatedAt wins
51// (createdAt when there is none); a tie keeps mine.
52export function mergeItems(mine, theirs) {
53 const byId = new Map(mine.map(i => [i.id, i]))
54 for (const t of theirs) {
55 const m = byId.get(t.id)
56 if (!m || stamp(t) > stamp(m)) byId.set(t.id, t)
57 }
58 return [...byId.values()]
59}
60
61// Whether two lists hold the same items, whatever their order: a local ref whose merge with origin's is the same
62// as origin's holds nothing origin lacks.
63export function sameItems(a, b) {
64 if (a.length !== b.length) return false
65 const theirs = new Map(b.map(i => [i.id, JSON.stringify(i)]))
66 const seen = new Set()
67 return a.every(i => {
68 if (seen.has(i.id)) return false
69 seen.add(i.id)
70 return theirs.get(i.id) === JSON.stringify(i)
71 })
72}
73lib/items.mjs 247 lines1// The loose ends of one repo: their shape, their states and every change a person or the sweep can make.
2// Transitions take `now` as an ISO string; queries take it in milliseconds.
3export const PRIORITIES = ['high', 'medium', 'low']
4export const CATEGORIES = ['bug', 'deuda', 'test', 'aviso', 'mejora']
5
6// English category names the tool and the sweep accept; they are stored under the keys above.
7export const CATEGORY_ALIASES = { debt: 'deuda', warning: 'aviso', improvement: 'mejora' }
8
9// The stored key for a category as Claude or the sweep wrote it; a value it does not know is returned as given, so
10// the filter refuses it.
11export function canonicalCategory(value) {
12 if (typeof value !== 'string') return undefined
13 const key = value.trim().toLowerCase()
14 if (CATEGORIES.includes(key)) return key
15 return Object.hasOwn(CATEGORY_ALIASES, key) ? CATEGORY_ALIASES[key] : value
16}
17const DAY = 24 * 60 * 60 * 1000
18export const CANDIDATE_TTL_MS = 7 * DAY
19export const STALE_MS = 14 * DAY
20export const CLOSED_WINDOW_MS = 7 * DAY
21// How long the ref keeps a closed item, and how many rejected it keeps (the filter compares against the last 50).
22export const CLOSED_RETENTION_MS = 30 * DAY
23export const REJECTED_KEEP = 50
24// Where a 0.3 version kept the items, inside the working tree; only the import reads it.
25export const LEGACY_FILE = '.claude/loose-ends.json'
26const LIVE = new Set(['open', 'doing'])
27const STATUSES = new Set(['candidate', 'open', 'doing', 'done', 'dismissed', 'rejected', 'expired'])
28const RANK = { high: 0, medium: 1, low: 2 }
29
30export function normalize(text) {
31 const key = String(text)
32 .toLowerCase()
33 .normalize('NFD')
34 .replace(/[̀-ͯ]/g, '')
35 .replace(/[^a-z0-9]+/g, ' ')
36 .trim()
37 .replace(/\s+/g, '')
38 return key || String(text).trim().toLowerCase()
39}
40
41// An item as any version wrote it, in this version's shape: a 0.3 `queued` is open again, the queue's fields go.
42export function upgrade(item) {
43 const { remindedAt, reason, closedBy, ...rest } = item
44 const status = item.status === 'queued' || !STATUSES.has(item.status) ? 'open' : item.status
45 return { ...rest, status, updatedAt: item.updatedAt ?? item.closedAt ?? item.createdAt }
46}
47
48export function parseItems(text) {
49 if (text == null || text.trim() === '') return { ok: true, items: [] }
50 if (/^(<<<<<<<|=======|>>>>>>>)/m.test(text)) return { ok: false, error: 'conflict' }
51 let data
52 try {
53 data = JSON.parse(text)
54 } catch {
55 return { ok: false, error: 'json' }
56 }
57 // a newer version's states would come back as open and be written over: its blob is left alone
58 if (data && typeof data.version === 'number' && data.version > 2) return { ok: false, error: 'version' }
59 if (!data || !Array.isArray(data.items)) return { ok: false, error: 'shape' }
60 if (!data.items.every(i => i && typeof i === 'object' && typeof i.id === 'string')) return { ok: false, error: 'shape' }
61 return { ok: true, items: data.items.map(upgrade) }
62}
63
64export function serializeItems(items) {
65 return JSON.stringify({ version: 2, items }, null, 2) + '\n'
66}
67
68function fields(input) {
69 return {
70 id: input.id,
71 text: String(input.text ?? '').trim().slice(0, 300),
72 category: CATEGORIES.includes(input.category) ? input.category : undefined,
73 priority: PRIORITIES.includes(input.priority) ? input.priority : 'medium',
74 source: input.source,
75 evidence: input.evidence ? String(input.evidence).slice(0, 400) : undefined,
76 file: input.file || undefined,
77 held: input.held ? true : undefined,
78 branch: input.branch ?? null,
79 createdAt: input.now,
80 updatedAt: input.now,
81 }
82}
83
84// A proposal from Claude or the sweep, waiting for the person. The filter has already run.
85export function propose(items, input) {
86 const item = { ...fields(input), status: 'candidate' }
87 if (item.text.length < 3) return { items, added: null }
88 return { items: [...items, item], added: item }
89}
90
91// A loose end the person writes by hand: open at once, unless the same text is already live or waiting.
92export function addManual(items, input) {
93 const item = { ...fields({ category: 'mejora', ...input, source: 'manual' }), status: 'open' }
94 if (item.text.length < 3) return { items, added: null }
95 const key = normalize(item.text)
96 if (items.some(i => (LIVE.has(i.status) || i.status === 'candidate') && normalize(i.text) === key)) return { items, added: null }
97 return { items: [...items, item], added: item }
98}
99
100// Patches the item with this id when its status is one of `from` (any status when `from` is null).
101function change(items, id, from, patch, now) {
102 return items.map(i => (i.id === id && (!from || from.includes(i.status)) ? { ...i, ...patch, updatedAt: now } : i))
103}
104
105export const save = (items, id, now) => change(items, id, ['candidate'], { status: 'open' }, now)
106export const reject = (items, id, now) => change(items, id, ['candidate'], { status: 'rejected', closedAt: now }, now)
107export const start = (items, id, now) => change(items, id, ['open', 'doing'], { status: 'doing' }, now)
108export const keepOpen = (items, id, now) => change(items, id, ['open', 'doing'], { status: 'open', proposal: undefined }, now)
109export const markDone = (items, id, now) => change(items, id, ['open', 'doing'], { status: 'done', proposal: undefined, closedAt: now }, now)
110export const dismiss = (items, id, now) => change(items, id, ['open', 'doing'], { status: 'dismissed', proposal: undefined, closedAt: now }, now)
111export const reopen = (items, id, now) => change(items, id, ['done', 'dismissed'], { status: 'open', proof: undefined, closedAt: undefined }, now)
112// A candidate a later answer shows done goes away by itself, with the sentence that proves it; ↺ brings it back to
113// review. It is stored as expired with `withdrawn`, a state 0.4 already reads and leaves alone.
114export const withdraw = (items, id, { quote, commit }, now) =>
115 change(items, id, ['candidate'], { status: 'expired', withdrawn: true, proof: { quote, ...(commit ? { commit } : {}) }, closedAt: now }, now)
116export const isWithdrawn = item => item.status === 'expired' && item.withdrawn === true
117export function unwithdraw(items, id, now) {
118 return items.map(i => (i.id === id && isWithdrawn(i) ? { ...i, status: 'candidate', withdrawn: undefined, proof: undefined, closedAt: undefined, updatedAt: now } : i))
119}
120export const touch = (items, id, now) => change(items, id, ['open', 'doing'], {}, now)
121
122export function proposeClose(items, id, { quote, commit }, now) {
123 return change(items, id, ['open', 'doing'], { proposal: { quote, ...(commit ? { commit } : {}), at: now } }, now)
124}
125
126export function confirmClose(items, id, now) {
127 return items.map(i =>
128 i.id === id && LIVE.has(i.status) && i.proposal
129 ? { ...i, status: 'done', proof: { quote: i.proposal.quote, ...(i.proposal.commit ? { commit: i.proposal.commit } : {}) }, proposal: undefined, closedAt: now, updatedAt: now }
130 : i,
131 )
132}
133
134export function setPriority(items, id, priority, now) {
135 return PRIORITIES.includes(priority) ? change(items, id, null, { priority }, now) : items
136}
137
138export function editText(items, id, text, now) {
139 const clean = String(text ?? '').trim()
140 return clean.length < 3 ? items : change(items, id, null, { text: clean.slice(0, 300) }, now)
141}
142
143// Puts an item back as it was before an action (Deshacer); the new updatedAt makes the restore win any merge.
144export function restore(items, previous, now) {
145 const back = { ...previous, updatedAt: now }
146 return items.some(i => i.id === previous.id) ? items.map(i => (i.id === previous.id ? back : i)) : [...items, back]
147}
148
149const msOf = iso => {
150 const ms = Date.parse(iso)
151 return Number.isFinite(ms) ? ms : 0
152}
153const created = item => msOf(item.createdAt)
154const touched = item => msOf(item.updatedAt ?? item.createdAt)
155
156export function expireCandidates(items, now) {
157 const at = Date.parse(now)
158 return items.map(i => (i.status === 'candidate' && at - created(i) >= CANDIDATE_TTL_MS ? { ...i, status: 'expired', closedAt: now, updatedAt: now } : i))
159}
160
161// An item's priority as the lists read it: one they do not know counts as medium.
162const priorityOf = item => (Object.hasOwn(RANK, item.priority) ? item.priority : 'medium')
163const byRank = (a, b) => RANK[priorityOf(a)] - RANK[priorityOf(b)]
164const byPriority = (a, b) => byRank(a, b) || created(a) - created(b)
165
166// What waits for the person; a candidate the sweep holds back a turn is not shown yet.
167export const candidates = items => items.filter(i => i.status === 'candidate' && !i.held).sort((a, b) => created(a) - created(b))
168export const heldCandidates = items => items.filter(i => i.status === 'candidate' && i.held).sort((a, b) => created(a) - created(b))
169// The held candidates with these ids go to the person.
170export function release(items, ids, now) {
171 const set = new Set(ids)
172 return items.map(i => (set.has(i.id) && i.status === 'candidate' && i.held ? { ...i, held: undefined, updatedAt: now } : i))
173}
174export const live = items => items.filter(i => LIVE.has(i.status)).sort(byPriority)
175export const isStale = (item, now) => LIVE.has(item.status) && now - touched(item) >= STALE_MS
176
177export function closedRecently(items, now) {
178 return items
179 .filter(i => (i.status === 'done' || i.status === 'dismissed' || isWithdrawn(i)) && msOf(i.closedAt) >= now - CLOSED_WINDOW_MS)
180 .sort((a, b) => msOf(b.closedAt) - msOf(a.closedAt))
181}
182
183export function rejectedTexts(items, max = 20) {
184 return items
185 .filter(i => i.status === 'rejected')
186 .sort((a, b) => msOf(b.closedAt) - msOf(a.closedAt))
187 .slice(0, max)
188 .map(i => i.text)
189}
190
191export function counts(items) {
192 const open = live(items)
193 const of = priority => open.filter(i => priorityOf(i) === priority).length
194 return { candidates: candidates(items).length, live: open.length, high: of('high'), medium: of('medium'), low: of('low') }
195}
196
197export const topUrgent = items => live(items).find(i => i.priority === 'high') ?? null
198
199// The item the band names next: the most urgent live one; at the same priority the one in progress, then the oldest.
200const doingFirst = (a, b) => (a.status === 'doing' ? 0 : 1) - (b.status === 'doing' ? 0 : 1)
201export function nextItem(items) {
202 // live() is already by priority then age, and sort is stable: moving the doing ones up keeps the oldest first
203 return live(items).sort((a, b) => byRank(a, b) || doingFirst(a, b))[0] ?? null
204}
205
206// The urgent item the prompt may propose: one still open (not started) with no closure waiting, and only while no
207// candidate waits for the person.
208export function suggestable(items) {
209 if (candidates(items).length) return null
210 return live(items).find(i => i.priority === 'high' && i.status === 'open' && !i.proposal) ?? null
211}
212
213// What the repo looks like to a session: every id, and the ones still to do.
214export function snapshot(items, now) {
215 return { at: now, ids: items.map(i => i.id), live: items.filter(i => LIVE.has(i.status)).map(i => i.id) }
216}
217
218// What changed since `seen`: items it did not know that wait for the person or are to do, and items it saw to do
219// that are closed now. Null without a usable snapshot.
220export function recap(items, seen) {
221 if (!seen || !Array.isArray(seen.ids) || !Array.isArray(seen.live)) return null
222 const known = new Set(seen.ids)
223 const wasLive = new Set(seen.live)
224 return {
225 at: seen.at,
226 fresh: items.filter(i => !known.has(i.id) && ((i.status === 'candidate' && !i.held) || LIVE.has(i.status))).length,
227 closed: items.filter(i => wasLive.has(i.id) && (i.status === 'done' || i.status === 'dismissed')).length,
228 }
229}
230
231const GONE_AFTER_RETENTION = new Set(['done', 'dismissed', 'expired'])
232
233// What the ref keeps, so its one blob never grows without end: closed, dismissed and expired items until 30 days
234// after closing, and the 50 newest rejected. Every machine applies the same rule, so a merge that brings a pruned
235// item back loses it again on the next write. The same array when nothing goes.
236export function prune(items, now) {
237 const rejected = items.filter(i => i.status === 'rejected').sort((a, b) => msOf(b.closedAt) - msOf(a.closedAt))
238 const keptRejected = new Set(rejected.slice(0, REJECTED_KEEP).map(i => i.id))
239 const kept = items.filter(i => {
240 if (i.status === 'rejected') return keptRejected.has(i.id)
241 if (!GONE_AFTER_RETENTION.has(i.status)) return true
242 const closed = Date.parse(i.closedAt)
243 return !Number.isFinite(closed) || now - closed <= CLOSED_RETENTION_MS
244 })
245 return kept.length === items.length ? items : kept
246}
247lib/filter.mjs 83 lines1import { CATEGORIES, normalize } from './items.mjs'
2import { t } from './i18n.mjs'
3
4// Openings of what is not a loose end, per language: checking, trying, running, waiting, deciding, reporting, pushing,
5// merging, deploying and sending to third parties. Both lists always apply, because Claude may answer in a language
6// other than the mod's. English verbs like merge, check, run, test, push or deploy are as often code work as chores, so they are banned only with their second word; the bare ones are those that are never code work.
7export const BANNED_STARTS = {
8 es: [
9 'verificar', 'comprobar', 'confirmar', 'revisar', 'probar', 'ejecutar', 'lanzar', 'esperar', 'vigilar', 'decidir', 'informar', 'reportar', 'preguntar', 'avisar',
10 'hacer push', 'subir la rama', 'subir los cambios', 'subir a main', 'hacer merge', 'mergear', 'fusionar y subir', 'fusionar la rama', 'desplegar', 'enviar',
11 ],
12 en: [
13 'verify', 'confirm', 'wait', 'decide', 'inform', 'notify', 'ask', 'tell',
14 'push to', 'push origin', 'push the branch', 'push changes', 'merge the branch', 'merge and push', 'merge into main', 'merge to main',
15 'deploy to', 'deploy the', 'check that', 'check whether', 'check if', 'run the tests', 'run the test suite', 'run ci', 'run the ci',
16 'test manually', 'test in production', 'test on staging', 'review the pr', 'review the pull request', 'report back', 'report to',
17 'watch the ci', 'watch the deploy', 'monitor the ci', 'monitor the deploy', 'monitor the deployment',
18 ],
19}
20const ALL_BANNED = [...BANNED_STARTS.es, ...BANNED_STARTS.en].map(start => start.split(' '))
21// What may come before the banned verb («hay que verificar…», "we need to check…"); the longer ones first.
22const LEAD_INS = [['hay', 'que'], ['we', 'need', 'to'], ['need', 'to'], ['we', 'have', 'to'], ['have', 'to'], ['we', 'should'], ['should'], ['must']]
23export const SIMILARITY = 0.6
24// The sweep repeats what Claude already noted in other words: its candidates are compared with the whole queue.
25export const SWEEP_SIMILARITY = 0.5
26export const REJECTED_WINDOW = 50
27// What a candidate must not repeat: what waits for the person and what is still to do.
28const POOL = new Set(['candidate', 'open', 'doing'])
29
30// Lowercase words without accents or punctuation.
31export function words(text) {
32 return String(text ?? '')
33 .toLowerCase()
34 .normalize('NFD')
35 .replace(/[̀-ͯ]/g, '')
36 .split(/[^a-z0-9]+/)
37 .filter(Boolean)
38}
39
40// Whether the text opens with a banned verb of either language, or with a lead-in and one.
41export function startsBanned(text) {
42 const all = words(text)
43 const lead = LEAD_INS.find(start => start.every((word, k) => all[k] === word))
44 const rest = lead ? all.slice(lead.length) : all
45 return ALL_BANNED.some(start => start.every((word, k) => rest[k] === word))
46}
47
48// Jaccard of the two word sets: shared words over all distinct words.
49export function similarity(a, b) {
50 const x = new Set(words(a))
51 const y = new Set(words(b))
52 if (!x.size || !y.size) return 0
53 let shared = 0
54 for (const word of x) if (y.has(word)) shared++
55 return shared / (x.size + y.size - shared)
56}
57
58const msOf = iso => Date.parse(iso) || 0
59
60// Why a candidate must not reach the person, as a code for rejectText, or null when it may.
61export function rejectReason(candidate, items) {
62 if (!CATEGORIES.includes(candidate.category)) return 'noCategory'
63 if (startsBanned(candidate.text)) return 'notCode'
64 const pool = items.filter(i => POOL.has(i.status))
65 const key = normalize(candidate.text)
66 if (pool.some(i => normalize(i.text) === key)) return 'duplicate'
67 if (candidate.evidence) {
68 const quote = normalize(candidate.evidence)
69 if (pool.some(i => i.evidence && normalize(i.evidence) === quote)) return 'sameQuote'
70 }
71 if (candidate.file && pool.some(i => i.file === candidate.file && similarity(i.text, candidate.text) >= SIMILARITY)) return 'sameFile'
72 if (candidate.source === 'sweep' && pool.some(i => similarity(i.text, candidate.text) >= SWEEP_SIMILARITY)) return 'similar'
73 const rejected = items
74 .filter(i => i.status === 'rejected')
75 .sort((a, b) => msOf(b.closedAt) - msOf(a.closedAt))
76 .slice(0, REJECTED_WINDOW)
77 if (rejected.some(i => similarity(i.text, candidate.text) >= SIMILARITY)) return 'likeRejected'
78 return null
79}
80
81// A reason code in words of `lang`.
82export const rejectText = (lang, code) => t(lang, `reject.${code}`)
83lib/detect.mjs 211 lines1import { CATEGORIES, PRIORITIES, canonicalCategory } from './items.mjs'
2import { t } from './i18n.mjs'
3import { formatCandidates } from './repos.mjs'
4
5export const SWEEP_MODEL = 'claude-haiku-4-5-20251001'
6export const MIN_ANSWER = 500
7export const MAX_NEW = 2
8export const MIN_EVIDENCE = 12
9
10// New items only come from long answers; closures are looked for in any answer while an item is in progress.
11export function shouldSweep(answer) {
12 return typeof answer === 'string' && answer.length >= MIN_ANSWER
13}
14
15function fenceSafe(text) {
16 return text.replace(/<<</g, '\u2039\u2039\u2039').replace(/>>>/g, '\u203A\u203A\u203A')
17}
18
19// What Haiku reads: the live items it may close (with ids), what waits for the person and what the person
20// rejected (so it proposes neither again), the candidate repos when another repo was touched, and the fenced answer.
21export function buildSweepPrompt(lang, answer, liveItems, waiting = [], candidates = [], rejected = [], log = '') {
22 const list = liveItems.length ? liveItems.map(i => `- ${i.id}: ${i.text}`).join('\n') : t(lang, 'sweepPrompt.none')
23 const queue = waiting.length ? `${t(lang, 'sweepPrompt.waiting')}\n${waiting.map(i => `- ${i.id}: ${i.text}`).join('\n')}\n\n` : ''
24 const no = rejected.length ? `${t(lang, 'sweepPrompt.rejected')}\n${rejected.map(x => `- ${x}`).join('\n')}\n\n` : ''
25 const repos = candidates.length ? `${t(lang, 'sweepPrompt.repos')}\n${formatCandidates(candidates)}\n\n` : ''
26 const commits = log.trim() ? `${t(lang, 'sweepPrompt.commits')}\n${log.trim().split('\n').map(l => `- ${fenceSafe(l)}`).join('\n')}\n\n` : ''
27 return `${t(lang, 'sweepPrompt.open')}\n${list}\n\n${queue}${no}${repos}${commits}${t(lang, 'sweepPrompt.answer')}\n<<<\n${fenceSafe(answer.slice(0, 12000))}\n>>>`
28}
29
30// Index one past the "}" that closes the object opening at `start`, or -1. Braces inside JSON strings do not count.
31function objectEnd(text, start) {
32 let depth = 0
33 let inString = false
34 for (let k = start; k < text.length; k++) {
35 const c = text[k]
36 if (inString) {
37 if (c === '\\') k++
38 else if (c === '"') inString = false
39 } else if (c === '"') inString = true
40 else if (c === '{') depth++
41 else if (c === '}' && --depth === 0) return k + 1
42 }
43 return -1
44}
45
46function firstJsonObject(text) {
47 if (typeof text !== 'string') return null
48 for (let start = text.indexOf('{'); start !== -1; start = text.indexOf('{', start + 1)) {
49 const end = objectEnd(text, start)
50 if (end === -1) continue
51 try {
52 const data = JSON.parse(text.slice(start, end))
53 if (data && typeof data === 'object' && !Array.isArray(data)) return data
54 } catch {}
55 }
56 return null
57}
58
59// Answer and quote go through the same normalization, so Markdown, quote style and list markers never decide
60// whether a quote is literal.
61const QUOTE_MARKS = /[`*_\u00AB\u00BB"\u201C\u201D'\u2019]/g
62const LIST_MARKER = /^[ \t]*(?:[-*\u2022]|\d+[.)])[ \t]+/gm
63
64function normalize(text) {
65 return text
66 .normalize('NFC')
67 .replace(/\u2039\u2039\u2039/g, '<<<')
68 .replace(/\u203A\u203A\u203A/g, '>>>')
69 .replace(LIST_MARKER, '')
70 .replace(QUOTE_MARKS, '')
71 .replace(/\s+/g, ' ')
72 .trim()
73 .toLowerCase()
74}
75
76function normalizeEvidence(evidence) {
77 return normalize(evidence.replace(/(?:\u2026|\.{3})\s*$/, ''))
78}
79
80// A quote is shown to the person between quotes: drop emphasis marks, keep underscores inside identifiers.
81function cleanEvidence(evidence) {
82 return evidence
83 .replace(/[`*]/g, '')
84 .replace(/(^|\s)_+(?=\S)/g, '$1')
85 .replace(/(\S)_+(?=\s|$)/g, '$1')
86 .trim()
87}
88
89// Whether `quote` is in `text` once both are normalized as the evidence gate does.
90export function containsQuote(text, quote) {
91 if (typeof text !== 'string' || typeof quote !== 'string') return false
92 const needle = normalizeEvidence(quote)
93 return needle.length >= MIN_EVIDENCE && normalize(text).includes(needle)
94}
95
96// The sentences of the answer that hold the quote, whole: a quote cut out of «… X sigue roto. Corregido.» must not
97// hide the «Corregido». Lines and sentence ends split; a quote across several sentences takes all of them, and a
98// short sentence right after (four words at most, like «Corregido.» or «Ya está.») or one that points back to it
99// («Lo arreglo con el panel.») comes along.
100const BACK_REFERENCE = /^(lo|la|los|las|eso|esto|esos|estos) /
101
102export function sentenceAround(answer, quote) {
103 if (typeof answer !== 'string' || typeof quote !== 'string') return null
104 const needle = normalizeEvidence(quote)
105 if (!needle) return null
106 const parts = answer.split(/\n+/).flatMap(line => line.split(/(?<=[.!?\u2026])\s+/)).map(normalize).filter(Boolean)
107 const joined = parts.join(' ')
108 const at = joined.indexOf(needle)
109 if (at === -1) return null
110 const out = []
111 let pos = 0
112 for (const [k, part] of parts.entries()) {
113 const end = pos + part.length
114 if (end > at && pos < at + needle.length) {
115 out.push(part)
116 const after = parts[k + 1]
117 if (end >= at + needle.length && after && (after.split(' ').length <= 4 || BACK_REFERENCE.test(after))) out.push(after)
118 }
119 pos = end + 1
120 }
121 return [...new Set(out)].join(' ')
122}
123
124// Words without accents, so the markers below match «está» and «esta» alike.
125const plain = text => text.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
126// Done in the same sentence («… Corregido.»), unless a negation sits right before it («todavía no está resuelto»). A bare
127// «ya está» counts only where the sentence ends: «ya está editado, sin tests» is half done.
128const DONE = /\b(corregid[oa]s?|arreglad[oa]s?|resuelt[oa]s?|solucionad[oa]s?|ya esta (hecho|arreglado|corregido|resuelto|listo)|ya esta(?=[.!;]|$)|fixed|done|resolved|implemented|added|removed|handled)/
129const NEGATED = /\b(no|sin|nunca|todavia no|aun no)\s+(esta\s+|estan\s+|queda\s+|quedan\s+)?$|\b(not|never|yet|still|to be|be|isn['’]t|aren['’]t|wasn['’]t|weren['’]t|hasn['’]t|haven['’]t)\s+(yet\s+|been\s+)?$/
130// Work under way or planned in this session: agents running, the next batch, «lo arreglo», «cuando terminen».
131const UNDER_WAY = /\b(he lanzado|lanzo (un|dos|tres|los|el|varios)|estoy lanzando|lo estan arreglando|va en la tanda|van en la tanda|en la (siguiente|proxima) tanda|(lo|la|los|las) (arreglo|corrijo|cambio|extraigo|hago) (ahora|con|en|al|despues)|se extraera|lo revisare|cuando terminen|cuando acaben|cuando termine el agente|en el siguiente paso|a continuacion (lo|la|los|las)|i['’]?m (now )?(fixing|adding|launching|working on|handling|doing)|i am (now )?(fixing|adding|launching|working on|handling|doing)|in progress|working on it|(i['’]?ll|i will) (fix|handle|do|add) (it|this|that|them) (now|next|after|in|with)|(launched|launching) (a|an|two|three|the|several) agents?|(when|once) the agents? (finish|are done|have finished))/
132// A choice left to the person.
133const DECISION = /\b(es decision tuya|decision tuya|decides tu|te lo pregunto|decidir si|your call|up to you|you decide|you choose|your decision|let me know if you want)\b/
134
135// Put off on purpose: later, another phase, outside the plan.
136const DEFERRED = /\b(mas adelante|para luego|otro momento|otro dia|en el futuro|fase \d+|fuera del plan|fuera de alcance|fuera del alcance|for later|later on|out of scope|not now|separately|in (a|another|the next) (pr|task|session|phase|pull request)|follow-?ups?)\b/
137
138// The sweep never says urgent: what it reads is high at most medium, and what is put off on purpose is low.
139export function sweepPriority(priority, sentence) {
140 if (typeof sentence === 'string' && DEFERRED.test(plain(sentence))) return 'low'
141 return priority === 'low' ? 'low' : 'medium'
142}
143
144// Why the sentence holding a quote says it is not a loose end, or null. Only the debug log shows it, in English.
145export function sentenceVerdict(sentence) {
146 if (typeof sentence !== 'string') return null
147 const text = plain(sentence)
148 for (const m of text.matchAll(new RegExp(DONE.source, 'g'))) {
149 if (!NEGATED.test(text.slice(Math.max(0, m.index - 20), m.index))) return 'the sentence says it is already done'
150 }
151 if (UNDER_WAY.test(text)) return 'the sentence says it is being done'
152 if (DECISION.test(text)) return 'the sentence leaves a decision to the user'
153 return null
154}
155
156export function parseSweepReply(text, liveIds, candidatePaths = [], answer = '', { allowNew = true, log = '' } = {}) {
157 const data = firstJsonObject(text)
158 if (!data) return null
159 const repos = new Set(candidatePaths)
160 const haystack = normalize(typeof answer === 'string' ? answer : '')
161 const literal = quote => {
162 const needle = typeof quote === 'string' ? normalizeEvidence(quote) : ''
163 return needle.length >= MIN_EVIDENCE && haystack.includes(needle) ? needle : null
164 }
165 // a closure may also quote the subject of a commit of the turn
166 const commits = normalize(typeof log === 'string' ? log : '')
167 const proven = quote => literal(quote) ?? (typeof quote === 'string' && normalizeEvidence(quote).length >= MIN_EVIDENCE && commits.includes(normalizeEvidence(quote)) ? quote : null)
168 let notLiteral = 0
169 const skipped = []
170 const seen = new Set()
171 // one sentence can back one item only
172 const quoted = (allowNew && Array.isArray(data.new) ? data.new : [])
173 .filter(x => x && typeof x.text === 'string' && x.text.trim().length >= 3)
174 .filter(x => {
175 const needle = literal(x.evidence)
176 if (!needle) {
177 notLiteral++
178 return false
179 }
180 if (seen.has(needle)) return false
181 seen.add(needle)
182 x.sentence = sentenceAround(answer, x.evidence)
183 const reason = sentenceVerdict(x.sentence)
184 if (reason) {
185 skipped.push({ text: x.text.trim(), reason })
186 return false
187 }
188 return true
189 })
190 const fresh = quoted.slice(0, MAX_NEW).map(x => ({
191 text: x.text.trim().slice(0, 300),
192 category: CATEGORIES.includes(canonicalCategory(x.category)) ? canonicalCategory(x.category) : undefined,
193 priority: sweepPriority(PRIORITIES.includes(x.priority) ? x.priority : 'medium', x.sentence),
194 evidence: cleanEvidence(x.evidence).slice(0, 400),
195 repo: typeof x.repo === 'string' && repos.has(x.repo) ? x.repo : undefined,
196 }))
197 const known = new Set(liveIds)
198 const closed = new Set()
199 const resolved = []
200 for (const r of Array.isArray(data.resolved) ? data.resolved : []) {
201 if (!r || typeof r.id !== 'string' || !known.has(r.id) || closed.has(r.id)) continue
202 if (!proven(r.quote)) {
203 notLiteral++
204 continue
205 }
206 closed.add(r.id)
207 resolved.push({ id: r.id, quote: cleanEvidence(r.quote).slice(0, 400) })
208 }
209 return { fresh, resolved, dropped: notLiteral, skipped }
210}
211lib/texts.mjs 48 lines1import { CATEGORIES } from './items.mjs'
2import { categoryWord, t } from './i18n.mjs'
3
4export const TOOL_NAME = 'note_loose_end'
5export const TOOL_ID = 'mcp__loose-ends__note_loose_end'
6
7export const toolDescription = lang => t(lang, 'tool.description')
8
9// The schema names the categories in the mod's language; the tool stores them under the keys of items.mjs.
10export function toolSchema(lang) {
11 return {
12 type: 'object',
13 properties: {
14 text: { type: 'string', description: t(lang, 'tool.text') },
15 category: { type: 'string', enum: CATEGORIES.map(c => categoryWord(lang, c)), description: t(lang, 'tool.category') },
16 priority: { type: 'string', enum: ['high', 'medium', 'low'], description: t(lang, 'tool.priority') },
17 evidence: { type: 'string', description: t(lang, 'tool.evidence') },
18 file: { type: 'string', description: t(lang, 'tool.file') },
19 repo: { type: 'string', description: t(lang, 'tool.repo') },
20 },
21 required: ['text', 'category', 'priority'],
22 }
23}
24
25export const toolGuide = lang => t(lang, 'tool.guide', { toolId: TOOL_ID })
26export const sweepSystem = lang => t(lang, 'sweep.system')
27
28export const toolTooShort = lang => t(lang, 'tool.tooShort')
29export const noGitNote = lang => t(lang, 'tool.noGit')
30export const notARepo = lang => t(lang, 'tool.notARepo')
31export const toolProposed = (lang, item) => t(lang, 'tool.proposed', { id: item.id, text: item.text })
32export const toolRejected = (lang, reason) => t(lang, 'tool.rejected', { reason })
33export const toolUnreadable = (lang, target, error) => t(lang, 'tool.unreadable', { target, error })
34
35export function formatContext(lang, liveItems, max = 10) {
36 const top = liveItems.filter(i => i.priority !== 'low').slice(0, max)
37 if (!top.length) return null
38 return [t(lang, 'context.head'), ...top.map(i => `- [${i.priority}] ${i.text} (${i.id}${i.branch ? t(lang, 'context.branch', { branch: i.branch }) : ''})`)].join('\n')
39}
40
41export function doNowText(lang, item) {
42 return `${t(lang, 'doNow.text', { id: item.id, text: item.text })}${item.evidence ? t(lang, 'doNow.evidence', { evidence: item.evidence }) : ''}`
43}
44
45export const suggestPrefix = lang => t(lang, 'suggest.prefix')
46export const suggestText = (lang, item) => `${suggestPrefix(lang)}${item.text}`
47export const passingText = (lang, items) => t(lang, 'passing.text', { list: items.map(i => `${i.text} (${i.id})`).join('; ') })
48lib/mood.mjs 22 lines1export const SLEEP_AFTER_MS = 30 * 60 * 1000
2export const FLASH_MS = 4000
3
4// Chispa's state from the loose ends first and from Claude second; the first that applies wins. She sleeps only
5// when the session is truly still: no activity for 30 min, nothing to review and nothing urgent.
6export function chispaMood({ candidates = 0, urgent = 0, flashUntil = 0, working = false, lastActivity = 0, now }) {
7 if (candidates > 0) return 'note'
8 if (urgent > 0) return 'worried'
9 if (now < flashUntil) return 'celebrate'
10 if (working) return 'working'
11 if (now - lastActivity >= SLEEP_AFTER_MS) return 'sleeping'
12 return 'idle'
13}
14
15export const isCommit = (command, failed) => !failed && /\bgit\s+commit\b/.test(command)
16export const isPush = (command, failed) => !failed && /\bgit\s+push\b/.test(command)
17
18export function bashFailed(result) {
19 if (!result) return false
20 return result.isError === true || /\b[1-9]\d* failed\b/i.test(result.text ?? '')
21}
22lib/repos.mjs 140 lines1const DRIVE = /^[A-Za-z]:/
2// a word that is, or ends in `=`, a drive path so far
3const DRIVE_WORD = /(?:^|=)[A-Za-z]:/
4
5// Backslashes to slashes and no trailing separator (the root, `/` or `C:/`, keeps its one), so a path has one spelling.
6export function normalizePath(path) {
7 const slashed = path.replace(/\\/g, '/')
8 return /^([A-Za-z]:)?\/$/.test(slashed) ? slashed : slashed.replace(/\/+$/, '') || '/'
9}
10
11// `/x` or `C:\x` / `C:/x`; a drive-relative `C:x` is not absolute.
12export const isAbsolutePath = p => typeof p === 'string' && (p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p))
13
14// Parent folder of a normalized path; a root is its own parent.
15export function parentPath(path) {
16 const parent = path.slice(0, path.lastIndexOf('/'))
17 if (parent === '') return '/'
18 return DRIVE.test(parent) && parent.length === 2 ? `${parent}/` : parent
19}
20
21// Tools whose path argument names a file; for the rest (Grep, Glob, Bash) it can be a folder.
22export const FILE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Read'])
23const PATH_FIELD = { Edit: 'file_path', Write: 'file_path', MultiEdit: 'file_path', Read: 'file_path', NotebookEdit: 'notebook_path', Grep: 'path', Glob: 'path' }
24// Words that end a shell command: the next word starts another one.
25const SEPARATORS = ';&|()\n'
26
27// Splits a shell command into commands made of words, with quotes and escapes resolved. Not a shell: no expansions.
28function shellCommands(command) {
29 const commands = [[]]
30 let word = ''
31 let started = false
32 let quote = null
33 const endWord = () => {
34 if (started) commands[commands.length - 1].push(word)
35 word = ''
36 started = false
37 }
38 for (let i = 0; i < command.length; i++) {
39 const c = command[i]
40 if (quote) {
41 if (c === quote) quote = null
42 else if (c === '\\' && quote === '"' && i + 1 < command.length) word += DRIVE_WORD.test(word) ? c : command[++i]
43 else word += c
44 } else if (c === "'" || c === '"') {
45 quote = c
46 started = true
47 } else if (c === '\\' && i + 1 < command.length) {
48 // in a drive path (`C:\Users`) the backslash is the separator, not an escape
49 word += DRIVE_WORD.test(word) ? c : command[++i]
50 started = true
51 } else if (SEPARATORS.includes(c)) {
52 endWord()
53 commands.push([])
54 } else if (/\s/.test(c)) endWord()
55 else {
56 word += c
57 started = true
58 }
59 }
60 endWord()
61 return commands.filter(words => words.length)
62}
63
64// A bare word counts as a path only with a second segment: `/api` in a grep pattern is not one, `/Users/x` is.
65const LOOSE_PATH = /^(\/|[A-Za-z]:\/)[^/]+\/[^/]+/
66
67const isCandidate = p => isAbsolutePath(p) && p !== '/' && !p.startsWith('/dev/')
68
69function bashPaths(command) {
70 const found = []
71 for (const words of shellCommands(command)) {
72 if (words[0] === 'cd') {
73 const target = words.slice(1).find(w => !w.startsWith('-') && w.toLowerCase() !== '/d')
74 if (target) found.push(target)
75 }
76 const git = words.indexOf('git')
77 if (git !== -1) for (let k = git + 1; k < words.length - 1; k++) if (words[k] === '-C') found.push(words[k + 1])
78 for (const w of words) {
79 if (isAbsolutePath(w)) {
80 if (LOOSE_PATH.test(normalizePath(w)) && !w.includes('://')) found.push(w)
81 } else {
82 const value = /^-[^=\s]*=((?:\/|[A-Za-z]:[\\/]).*)$/.exec(w)
83 if (value) found.push(value[1])
84 }
85 }
86 }
87 return found
88}
89
90// Absolute paths a tool call points at, in order and without repeats. Relative ones are left out: without
91// the shell's working directory they cannot be told apart from the session's.
92export function candidatePaths(tool, input) {
93 if (!input || typeof input !== 'object') return []
94 let found = []
95 if (tool === 'Bash') found = typeof input.command === 'string' ? bashPaths(input.command) : []
96 else if (PATH_FIELD[tool]) found = [input[PATH_FIELD[tool]]]
97 return [...new Set(found.filter(isAbsolutePath).map(normalizePath).filter(isCandidate))]
98}
99
100export function repoName(path) {
101 return path.replace(/\/+$/, '').split('/').pop() || path
102}
103
104// The session repo first (when there is one), then the touched ones, each once.
105export function candidateRepos(sessionRepo, touched) {
106 return [...new Set([...(sessionRepo ? [sessionRepo] : []), ...touched])]
107}
108
109export function formatCandidates(paths) {
110 return paths.map(p => `- ${repoName(p)}: ${p}`).join('\n')
111}
112
113// Case does not matter on a drive-letter path (Windows); elsewhere it does.
114const key = p => (DRIVE.test(p) ? p.toLowerCase() : p)
115
116// Repos where loose ends must never be written: the home directory itself and everything under its `.claude`
117// (Claude Code's own folder, whose plugin caches and marketplaces are git repos). A worktree of a project, which
118// Claude Code puts under `<repo>/.claude/worktrees`, is a real repo. Without HOME, only the plugin folders and a
119// bare `.claude` are recognized.
120export function isIgnoredRepo(top, home) {
121 const path = key(normalizePath(top))
122 if (typeof home === 'string' && home !== '') {
123 const h = key(normalizePath(home))
124 return path === h || path === `${h}/.claude` || path.startsWith(`${h}/.claude/`)
125 }
126 const segments = path.split('/')
127 const claude = segments.lastIndexOf('.claude')
128 return claude !== -1 && (claude === segments.length - 1 || segments[claude + 1] === 'plugins')
129}
130
131// A path as the repo at `root` sees it: relative to its toplevel, with forward slashes. An absolute path outside
132// the repo, the toplevel itself or nothing gives undefined; a relative path is taken as relative to the repo.
133export function relativeTo(root, path) {
134 if (typeof path !== 'string' || !path.trim()) return undefined
135 const p = normalizePath(path.trim())
136 if (!isAbsolutePath(p)) return p.replace(/^(\.\/)+/, '') || undefined
137 const top = normalizePath(root)
138 return key(p).startsWith(`${key(top)}/`) ? p.slice(top.length + 1) : undefined
139}
140lib/screens.mjs 389 lines1import { TERMINAL_FACES, chispaSvg, moodLabel } from './chispa.mjs'
2import { categoryWord, priorityWord, t, tn } from './i18n.mjs'
3
4// The two hrefs of the band's link (band-line): with the first the pane opens, with the second the last close is undone.
5export const OPEN_PANE_HREF = 'file:///loose-ends/ver'
6export const UNDO_HREF = 'file:///loose-ends/deshacer'
7const BAND_TEXT_MAX = 60
8// The priorities the band counts, most urgent first.
9const PRIORITY_ORDER = ['high', 'medium', 'low']
10// Where the ref can stand against origin.
11const SYNC_STATES = ['synced', 'ahead', 'failed', 'local']
12
13export function clip(text, max = BAND_TEXT_MAX) {
14 const s = String(text ?? '')
15 return s.length > max ? `${s.slice(0, max - 1).trimEnd()}…` : s
16}
17
18export function recapText(lang, { since, fresh, closed }) {
19 const parts = [fresh ? tn(lang, 'recap.fresh', fresh) : '', closed ? tn(lang, 'recap.closed', closed) : ''].filter(Boolean)
20 return t(lang, 'recap.since', { since, parts: parts.join(' · ') })
21}
22
23// What the terminal's line says, the first that applies: unreadable, to review, urgent, just closed, since last time, open.
24export function bandLine(m) {
25 const lang = m.lang
26 if (m.fileError) return { text: t(lang, 'band.unreadable'), action: 'open', label: t(lang, 'action.view'), warning: true }
27 const open = m.counts.live ? tn(lang, 'band.open', m.counts.live) : ''
28 if (m.counts.candidates) {
29 const review = t(lang, 'band.toReview', { n: m.counts.candidates })
30 return { text: [review, open].filter(Boolean).join(' · '), action: 'open', label: t(lang, 'action.review') }
31 }
32 if (m.urgent) return { text: t(lang, 'band.urgent', { text: clip(m.urgent.text) }), action: 'open', label: t(lang, 'action.view') }
33 if (m.justClosed) return { text: t(lang, 'band.closed', { text: clip(m.justClosed.text) }), action: 'undo', label: t(lang, 'action.undo') }
34 if (m.recap && (m.recap.fresh || m.recap.closed)) return { text: recapText(lang, m.recap), action: 'open', label: t(lang, 'action.view') }
35 if (open) return { text: open, action: 'open', label: t(lang, 'action.view') }
36 return null
37}
38
39// What the desktop band says: a border color, the top line, the dim segments under it, the button and the link.
40// The first that applies: unreadable, to review, just closed, since last time, the next loose end; nothing open, null.
41export function bandCard(m) {
42 const lang = m.lang
43 const next = m.next
44 const sync = SYNC_STATES.includes(m.sync) ? [{ text: t(lang, `band.sync.${m.sync}`) }] : []
45 const doNext = next ? { label: t(lang, 'action.do'), action: 'doNow', id: next.id } : null
46 const nextBorder = next?.priority === 'high' ? 'error' : 'promptBorder'
47 if (m.fileError) {
48 return { border: 'error', top: { text: t(lang, 'band.unreadable'), color: 'warning' }, bottom: [{ text: t(lang, 'band.unreadableHint') }], button: null, link: { label: t(lang, 'action.view'), action: 'open' } }
49 }
50 if (m.counts.candidates) {
51 const bottom = [...(next ? [{ text: t(lang, 'band.next', { text: clip(next.text) }) }] : []), ...(m.counts.live ? [{ text: tn(lang, 'band.open', m.counts.live) }] : []), ...sync]
52 return { border: 'claude', top: { text: tn(lang, 'band.waiting', m.counts.candidates) }, bottom, button: { label: t(lang, 'action.review'), action: 'open' }, link: null }
53 }
54 if (m.justClosed) {
55 const bottom = next ? [{ text: tn(lang, 'band.left', m.counts.live) }, { text: t(lang, 'band.nextAfter', { text: clip(next.text) }) }] : [{ text: t(lang, 'band.nothingOpen') }]
56 return { border: 'success', top: { text: t(lang, 'band.closed', { text: clip(m.justClosed.text) }) }, bottom, button: doNext, link: { label: t(lang, 'action.undo'), action: 'undo' } }
57 }
58 if (m.recap && (m.recap.fresh || m.recap.closed)) {
59 const bottom = next ? [{ text: t(lang, 'band.next', { text: clip(next.text) }), priority: next.priority }] : [{ text: t(lang, 'band.nothingOpen') }]
60 return { border: nextBorder, top: { text: recapText(lang, m.recap) }, bottom, button: doNext, link: { label: t(lang, 'band.notebook'), action: 'open' } }
61 }
62 if (!next) return null
63 const label = next.status === 'doing' ? t(lang, 'band.doing') : next.priority === 'high' ? t(lang, 'band.urgentLabel') : t(lang, 'band.nextLabel')
64 const byPriority = PRIORITY_ORDER.filter(p => m.counts[p]).map(p => ({ text: tn(lang, `band.count.${p}`, m.counts[p]), priority: p }))
65 return {
66 border: nextBorder,
67 top: { text: clip(next.text, 120), priority: next.priority, ...(next.file ? { file: next.file } : {}) },
68 bottom: [{ text: label }, ...byPriority, ...sync],
69 button: doNext,
70 link: { label: t(lang, 'band.notebook'), action: 'open' },
71 }
72}
73
74// The top line of the card: the priority dot, the text cut to fit, and the file, dim.
75function cardTop(el, top) {
76 const parts = []
77 if (top.priority) parts.push(dot(el, top.priority))
78 parts.push(el.Box({ flexShrink: 1, minWidth: 0, children: [el.Text({ wrap: 'truncate-end', ...(top.color ? { color: top.color } : {}), children: top.text })] }))
79 if (top.file) parts.push(el.Text({ dimColor: true, children: top.file }))
80 return el.Box({ key: 'band-top', flexDirection: 'row', gap: 1, overflow: 'hidden', children: parts })
81}
82
83// The bottom line: the segments, dim, a dot before each one that names a priority, joined by «·».
84function cardBottom(el, bottom) {
85 const parts = []
86 bottom.forEach((seg, i) => {
87 if (i) parts.push(el.Text({ dimColor: true, children: '·' }))
88 if (seg.priority) parts.push(dot(el, seg.priority))
89 parts.push(el.Text({ dimColor: true, wrap: 'truncate-end', children: seg.text }))
90 })
91 return el.Box({ key: 'band-bottom', flexDirection: 'row', gap: 1, overflow: 'hidden', children: parts })
92}
93
94// What Chispa says when the band has nothing to show: the first that applies of Claude working, Chispa asleep, what
95// was closed this week and the time of day. The variant goes by the day's number, so it changes once a day.
96export function idlePhrase(m) {
97 const lang = m.lang
98 const variant = Math.abs(m.day ?? 0) % 2
99 if (m.working) return t(lang, `idle.working.${variant}`)
100 if (m.mood === 'sleeping') return t(lang, `idle.sleeping.${variant}`)
101 if (m.closedWeek > 0) return tn(lang, `idle.week.${variant}`, m.closedWeek)
102 const hour = m.hour ?? 12
103 const part = hour >= 6 && hour < 14 ? 'morning' : hour >= 14 && hour < 21 ? 'afternoon' : 'night'
104 return t(lang, `idle.${part}.${variant}`)
105}
106
107// The local hour and the number of the local calendar day, for idlePhrase.
108export function localClock(now) {
109 const d = new Date(now)
110 return { hour: d.getHours(), day: Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / (24 * 60 * 60 * 1000)) }
111}
112
113export function renderBand(el, surface, m, actions) {
114 if (surface === 'terminal') {
115 const line = bandLine(m)
116 const press = () => (line?.action === 'undo' ? actions.undoClose() : actions.openPane())
117 const children = [el.Text({ dimColor: true, children: TERMINAL_FACES[m.mood] ?? TERMINAL_FACES.idle })]
118 if (line) {
119 children.push(el.Text({ dimColor: true, ...(line.warning ? { color: 'warning' } : {}), children: line.text }))
120 children.push(el.Button({ key: line.action === 'undo' ? 'undo-close' : 'open-pane', label: line.label, plain: true, dimColor: true, onPress: press }))
121 } else {
122 children.push(el.Text({ dimColor: true, children: idlePhrase(m) }))
123 }
124 return el.Box({ flexDirection: 'row', alignItems: 'center', gap: 1, children })
125 }
126 const chispa = el.Svg({ source: chispaSvg(m.mood), alt: moodLabel(m.lang, m.mood), width: 44, height: 37 })
127 const card = bandCard(m)
128 const frame = { flexDirection: 'row', alignItems: 'center', gap: 1, borderStyle: 'round', backgroundColor: 'userMessageBackground', paddingX: 1 }
129 if (!card) {
130 // nothing waits: the same card, with Chispa's phrase in gray and nothing to press
131 const phrase = el.Box({ flexGrow: 1, flexShrink: 1, minWidth: 0, children: [el.Text({ dimColor: true, wrap: 'truncate-end', children: idlePhrase(m) })] })
132 return el.Box({ ...frame, borderColor: 'promptBorder', children: [chispa, phrase] })
133 }
134 const run = target => (target.action === 'doNow' ? actions.doNow(target.id) : target.action === 'undo' ? actions.undoClose() : actions.openPane())
135 const side = []
136 if (card.button) {
137 side.push(el.Button({ key: 'band-act', label: card.button.label, variant: 'primary', dimColor: card.button.action === 'doNow' && m.working, onPress: () => run(card.button) }))
138 }
139 if (card.link) {
140 const href = card.link.action === 'undo' ? UNDO_HREF : OPEN_PANE_HREF
141 side.push(el.Markdown({ key: 'band-line', text: `[${card.link.label}](${href})`, pressableLinks: [href], onLinkPress: () => run(card.link) }))
142 }
143 // a live card: the border says the state, the fill sets it apart from the transcript
144 return el.Box({
145 ...frame,
146 borderColor: card.border,
147 children: [
148 chispa,
149 el.Box({ flexDirection: 'column', flexGrow: 1, flexShrink: 1, minWidth: 0, children: [cardTop(el, card.top), cardBottom(el, card.bottom)] }),
150 el.Box({ flexDirection: 'column', alignItems: 'flex-end', children: side }),
151 ],
152 })
153}
154
155// The first verbatim occurrence of `quote` in a message's markdown, in bold; the text as it was when it is not there.
156export function emphasize(text, quote) {
157 if (!quote) return text
158 const at = text.indexOf(quote)
159 return at === -1 ? text : `${text.slice(0, at)}**${quote}**${text.slice(at + quote.length)}`
160}
161
162// The one dim line of a card the person already answered.
163function answeredText(lang, card) {
164 const commit = card.item.proof?.commit ? ` · ${card.item.proof.commit}` : ''
165 if (card.state === 'saved') return t(lang, 'triage.saved', { repo: card.repoName })
166 if (card.state === 'rejected') return t(lang, 'triage.rejected')
167 if (card.state === 'closed') return `${t(lang, 'triage.closed')}${commit}`
168 if (card.state === 'kept') return t(lang, 'triage.kept')
169 return `${t(lang, 'triage.withdrawn')}${commit}`
170}
171
172// «¿Resuelto?», with the commit when the proposal has one.
173function resolvedHead(lang, proposal) {
174 return [t(lang, 'triage.resolved'), proposal?.commit ? t(lang, 'triage.commit', { sha: proposal.commit }) : ''].filter(Boolean).join(' · ')
175}
176
177// The card under the message where a candidate or a proposed closure was born.
178export function renderTriage(el, surface, card, actions) {
179 const { item, lang } = card
180 const id = item.id
181 if (card.state) {
182 return el.Box({
183 key: `triage-${id}`,
184 flexDirection: 'row',
185 gap: 1,
186 children: [el.Text({ dimColor: true, children: answeredText(lang, card) }), el.Button({ key: `tri-undo-${id}`, label: t(lang, 'action.undo'), plain: true, onPress: () => actions.undo(id) })],
187 })
188 }
189 if (card.kind === 'proposal') {
190 return framed(el, `triage-${id}`, 'success', [
191 el.Text({ color: 'success', children: resolvedHead(lang, item.proposal) }),
192 el.Text({ wrap: 'wrap', children: item.text }),
193 el.Text({ dimColor: true, italic: true, wrap: 'wrap', children: t(lang, 'triage.proof', { quote: card.quote }) }),
194 buttons(el, [
195 el.Button({ key: `tri-confirm-${id}`, label: t(lang, 'action.confirmClose'), variant: 'primary', onPress: () => actions.confirm(id) }),
196 el.Button({ key: `tri-keep-${id}`, label: t(lang, 'action.keepOpen'), onPress: () => actions.keep(id) }),
197 ]),
198 ])
199 }
200 const head = [t(lang, 'triage.candidate'), categoryWord(lang, item.category), priorityWord(lang, item.priority)].filter(Boolean).join(' · ')
201 const parts = [el.Text({ color: 'warning', children: head }), el.Text({ wrap: 'wrap', children: item.text })]
202 if (card.editing && surface !== 'mobile') {
203 parts.push(el.Input({ key: `tri-edit-input-${id}`, value: item.text, submitLabel: t(lang, 'action.saveQuiet'), onSubmit: value => actions.saveEdited(id, value) }))
204 } else {
205 const row = [
206 el.Button({ key: `tri-save-${id}`, label: t(lang, 'action.save'), variant: 'primary', onPress: () => actions.save(id) }),
207 el.Button({ key: `tri-reject-${id}`, label: t(lang, 'action.reject'), onPress: () => actions.reject(id) }),
208 ]
209 if (surface !== 'mobile') row.push(el.Button({ key: `tri-edit-${id}`, label: t(lang, 'action.edit'), plain: true, onPress: () => actions.startEdit(id) }))
210 parts.push(buttons(el, row))
211 }
212 return framed(el, `triage-${id}`, 'claude', parts)
213}
214
215// The dot of an item: low priority has no color of its own, it is only dim.
216const DOT = { high: { color: 'error' }, medium: { color: 'warning' }, low: { dimColor: true } }
217const dot = (el, priority) => el.Text({ ...(DOT[priority] ?? DOT.medium), children: '●' })
218
219export function ago(lang, iso, now) {
220 const ms = now - Date.parse(iso)
221 if (!Number.isFinite(ms)) return ''
222 const min = Math.max(0, Math.round(ms / 60000))
223 if (min < 1) return t(lang, 'ago.now')
224 if (min < 60) return t(lang, 'ago.minutes', { n: min })
225 const h = Math.round(min / 60)
226 if (h < 24) return t(lang, 'ago.hours', { n: h })
227 return t(lang, 'ago.days', { n: Math.round(h / 24) })
228}
229
230// A section title: bold, then the count in dim.
231function heading(el, title, count) {
232 return el.Box({ flexDirection: 'row', gap: 2, children: [el.Text({ bold: true, children: title }), el.Text({ dimColor: true, children: String(count) })] })
233}
234
235// A section: its title and its rows, one row of air between them.
236function section(el, title, count, rows) {
237 return el.Box({ flexDirection: 'column', gap: 1, children: [heading(el, title, count), ...rows] })
238}
239
240// Every item is a card, in the pane and under a message: a round border in a theme color, so it follows the
241// app's light or dark theme.
242const framed = (el, key, borderColor, children) => el.Box({ key, flexDirection: 'column', borderStyle: 'round', borderColor, paddingX: 1, children })
243
244// A row that may wrap: air between its items, none between its lines, or a wrapped row opens a wide hole.
245const buttons = (el, children) => el.Box({ flexDirection: 'row', columnGap: 1, flexWrap: 'wrap', children })
246
247// Where the ref stands, after a dot that says it at a glance; a failed push is said in the warning color.
248const SYNC_DOT = { synced: { color: 'success' }, local: { dimColor: true }, ahead: { color: 'warning' }, failed: { color: 'error' } }
249
250function syncLine(el, m, actions) {
251 if (!SYNC_STATES.includes(m.sync)) return null
252 const text = t(m.lang, `pane.sync.${m.sync}`)
253 const parts = [el.Text({ ...SYNC_DOT[m.sync], children: '●' }), el.Text(m.sync === 'failed' ? { color: 'warning', children: text } : { dimColor: true, children: text })]
254 if (m.sync === 'ahead' || m.sync === 'failed') parts.push(el.Button({ key: 'push-now', label: t(m.lang, 'action.push'), plain: true, onPress: () => actions.push() }))
255 return el.Box({ flexDirection: 'row', gap: 1, children: parts })
256}
257
258function candidateRow(el, item, m, actions) {
259 const lang = m.lang
260 const meta = [categoryWord(lang, item.category), ago(lang, item.createdAt, m.now)].filter(Boolean).join(' · ')
261 const parts = [el.Text({ wrap: 'wrap', children: item.text })]
262 if (meta) parts.push(el.Text({ dimColor: true, children: meta }))
263 if (item.evidence) parts.push(el.Text({ dimColor: true, italic: true, wrap: 'wrap', children: t(lang, 'quote', { text: item.evidence }) }))
264 parts.push(
265 buttons(el, [
266 el.Button({ key: `save-${item.id}`, label: t(lang, 'action.save'), variant: 'primary', onPress: () => actions.save(item.id) }),
267 el.Button({ key: `reject-${item.id}`, label: t(lang, 'action.reject'), onPress: () => actions.reject(item.id) }),
268 ]),
269 )
270 return framed(el, `cand-${item.id}`, 'claude', parts)
271}
272
273// What sits under the text of an open item, indented past its dot.
274const under = (el, children) => el.Box({ flexDirection: 'row', columnGap: 1, flexWrap: 'wrap', paddingLeft: 2, children })
275
276function liveRow(el, surface, item, m, actions) {
277 const lang = m.lang
278 const editable = surface !== 'mobile'
279 const id = item.id
280 const meta = [
281 categoryWord(lang, item.category),
282 ago(lang, item.createdAt, m.now),
283 item.branch && item.branch !== m.branch ? t(lang, 'pane.bornIn', { branch: item.branch }) : '',
284 item.status === 'doing' ? t(lang, 'pane.doing') : '',
285 ]
286 .filter(Boolean)
287 .join(' · ')
288 const parts = []
289 if (editable && m.editing === id) {
290 parts.push(el.Input({ key: `edit-input-${id}`, value: item.text, submitLabel: t(lang, 'action.saveQuiet'), onSubmit: value => actions.saveEdited(id, value) }))
291 } else {
292 parts.push(el.Box({ flexDirection: 'row', gap: 1, children: [dot(el, item.priority), el.Text({ wrap: 'wrap', children: item.text })] }))
293 }
294 // what is said about it, then the file as a chip, the way code is set apart in the transcript
295 const line = []
296 if (meta) line.push(el.Text({ dimColor: true, wrap: 'wrap', children: meta }))
297 if (item.file) line.push(el.Text({ backgroundColor: 'userMessageBackground', children: ` ${item.file} ` }))
298 if (line.length) parts.push(under(el, line))
299 if (item.proposal) {
300 parts.push(under(el, [el.Text({ color: 'success', children: resolvedHead(lang, item.proposal) })]))
301 parts.push(under(el, [el.Text({ dimColor: true, italic: true, wrap: 'wrap', children: t(lang, 'triage.proof', { quote: item.proposal.quote }) })]))
302 parts.push(
303 under(el, [
304 el.Button({ key: `confirm-${id}`, label: t(lang, 'action.confirmClose'), variant: 'primary', onPress: () => actions.confirm(id) }),
305 el.Button({ key: `keep-${id}`, label: t(lang, 'action.keepOpen'), onPress: () => actions.keep(id) }),
306 ]),
307 )
308 }
309 if (item.stale) {
310 parts.push(under(el, [el.Text({ color: 'warning', children: t(lang, 'pane.stale') }), el.Button({ key: `fresh-${id}`, label: t(lang, 'action.yes'), plain: true, onPress: () => actions.keepFresh(id) })]))
311 }
312 // the controls on one row: the actions, then the priority word that cycles and edit, quieter
313 const controls = [
314 el.Button({ key: `now-${id}`, label: t(lang, 'action.do'), variant: 'primary', dimColor: m.working, onPress: () => actions.doNow(id) }),
315 el.Button({ key: `done-${id}`, label: t(lang, 'action.done'), onPress: () => actions.done(id) }),
316 el.Button({ key: `dismiss-${id}`, label: t(lang, 'action.dismiss'), plain: true, onPress: () => actions.dismiss(id) }),
317 el.Button({ key: `prio-${id}`, label: priorityWord(lang, item.priority), plain: true, dimColor: true, onPress: () => actions.cyclePriority(id) }),
318 ]
319 if (editable) controls.push(el.Button({ key: `edit-${id}`, label: t(lang, 'action.editQuiet'), plain: true, dimColor: true, onPress: () => actions.startEdit(id) }))
320 parts.push(under(el, controls))
321 return framed(el, `item-${id}`, 'promptBorder', parts)
322}
323
324// One line per closed item: the text is cut to fit, so the check, the proof and ↺ always show.
325function closedRow(el, item, lang, actions) {
326 const withdrawn = item.status === 'expired' && item.withdrawn === true
327 const commit = item.proof?.commit ? ` · ${item.proof.commit}` : ''
328 const proof = withdrawn ? `${t(lang, 'pane.withdrawn')}${commit}` : item.proof?.commit ?? (item.proof ? t(lang, 'pane.withProof') : '')
329 const dismissed = item.status === 'dismissed' || withdrawn
330 const parts = [
331 el.Text({ ...(dismissed ? { dimColor: true } : { color: 'success' }), children: '✓' }),
332 el.Box({ flexGrow: 1, flexShrink: 1, minWidth: 0, children: [el.Text({ wrap: 'truncate-end', dimColor: dismissed, strikethrough: dismissed, children: item.text })] }),
333 ]
334 // the proof never shrinks, so «con prueba» stays on the row's one line
335 if (proof) parts.push(el.Box({ flexShrink: 0, children: [el.Text({ dimColor: true, children: proof })] }))
336 parts.push(el.Button({ key: `reopen-${item.id}`, label: '↺', plain: true, dimColor: true, onPress: () => actions.reopen(item.id) }))
337 return el.Box({ key: `closed-${item.id}`, flexDirection: 'row', gap: 1, children: parts })
338}
339
340export function renderPane(el, surface, m, actions) {
341 const lang = m.lang
342 const out = []
343 if (m.fileError) out.push(el.Text({ color: 'warning', wrap: 'wrap', children: t(lang, 'pane.unreadable', { path: m.repoPath, error: m.fileError }) }))
344 if (m.noRepo) {
345 out.push(el.Text({ dimColor: true, children: t(lang, 'pane.noRepo') }))
346 return el.Box({ flexDirection: 'column', children: out })
347 }
348 // the pane's own title already says Cuaderno: the header names the repo and where its ref stands
349 const sync = syncLine(el, m, actions)
350 out.push(el.Box({ flexDirection: 'column', children: [el.Text({ bold: true, children: m.repoName }), ...(sync ? [sync] : [])] }))
351 if (m.notice) {
352 out.push(
353 el.Box({
354 flexDirection: 'row',
355 gap: 1,
356 children: [
357 el.Box({ flexGrow: 1, flexShrink: 1, children: [el.Text({ dimColor: true, wrap: 'wrap', children: m.notice })] }),
358 el.Button({ key: 'notice-ok', label: t(lang, 'action.gotIt'), plain: true, onPress: () => actions.dismissNotice() }),
359 ],
360 }),
361 )
362 }
363 if (surface !== 'mobile') out.push(el.Input({ key: 'add-item', placeholder: t(lang, 'pane.addPlaceholder'), submitLabel: t(lang, 'pane.addSubmit'), onSubmit: value => actions.add(value) }))
364
365 if (m.waiting.length) out.push(section(el, t(lang, 'pane.toReview'), m.waiting.length, m.waiting.map(item => candidateRow(el, item, m, actions))))
366
367 const open = m.live.length ? m.live.map(item => liveRow(el, surface, item, m, actions)) : [el.Text({ dimColor: true, children: t(lang, 'pane.nothingOpen') })]
368 out.push(section(el, t(lang, 'pane.open'), m.live.length, open))
369
370 if (m.closed.length) {
371 out.push(el.Box({ flexDirection: 'column', children: [heading(el, t(lang, 'pane.closedWeek'), m.closed.length), ...m.closed.map(item => closedRow(el, item, lang, actions))] }))
372 }
373 if (m.learned) out.push(el.Text({ dimColor: true, children: tn(lang, 'pane.learned', m.learned) }))
374 return el.Box({ flexDirection: 'column', gap: 1, children: out })
375}
376
377// When the last session saw the repo, in words: «hace un rato» the same day, «ayer», or the date.
378export function sinceText(lang, iso, now) {
379 const then = new Date(iso)
380 if (!Number.isFinite(then.getTime())) return t(lang, 'since.unknown')
381 const day = d => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
382 const days = Math.round((day(new Date(now)) - day(then)) / (24 * 60 * 60 * 1000))
383 if (days <= 0) return t(lang, 'since.recent')
384 if (days === 1) return t(lang, 'since.yesterday')
385 const pad = n => String(n).padStart(2, '0')
386 const month = t(lang, 'since.months').split(' ')[then.getMonth()]
387 return t(lang, 'since.date', { dd: pad(then.getDate()), mm: pad(then.getMonth() + 1), d: then.getDate(), month })
388}
389lib/i18n.mjs 47 lines1import { en } from './locales/en.mjs'
2import { es } from './locales/es.mjs'
3
4// The languages the mod speaks; every text goes through t() with one of them.
5export const LANGUAGES = ['es', 'en']
6const TABLES = { es, en }
7const PRIORITY_NAMES = ['high', 'medium', 'low']
8
9// The language from the `language` option: 'es' or 'en' as given; anything else is auto, where the first of
10// LC_ALL, LC_MESSAGES and LANG with a value decides, then APPLE_LOCALE (the macOS system language, which desktop
11// apps often get instead of those variables); Spanish when it starts with "es"; none set is English.
12export function pickLanguage(option, env = {}) {
13 if (option === 'es' || option === 'en') return option
14 const value = [env.LC_ALL, env.LC_MESSAGES, env.LANG, env.APPLE_LOCALE].find(v => typeof v === 'string' && v.trim() !== '')
15 return value && value.trim().toLowerCase().startsWith('es') ? 'es' : 'en'
16}
17
18// Puts each {name} of `text` in from `params`, in one pass: what goes in is never read again, and a name with no
19// param stays as written (so JSON in a prompt is left alone).
20export function fill(text, params = {}) {
21 return String(text).replace(/\{(\w+)\}/g, (all, name) => (Object.hasOwn(params, name) ? String(params[name]) : all))
22}
23
24// The text of `key` in `lang`; English when that language lacks it, the key itself when no language has it.
25export function t(lang, key, params = {}) {
26 const table = Object.hasOwn(TABLES, lang) ? TABLES[lang] : en
27 const text = Object.hasOwn(table, key) ? table[key] : Object.hasOwn(en, key) ? en[key] : key
28 return fill(text, params)
29}
30
31// A text that agrees with a count: `${key}.one` for 1, `${key}.other` otherwise, with the count as {n}.
32export function tn(lang, key, n, params = {}) {
33 return t(lang, `${key}.${n === 1 ? 'one' : 'other'}`, { n, ...params })
34}
35
36// The word for a stored category key; a value the mod does not know is shown as it is.
37export function categoryWord(lang, category) {
38 if (!category) return ''
39 const key = `category.${category}`
40 return Object.hasOwn(en, key) ? t(lang, key) : String(category)
41}
42
43// The word for a priority; one the mod does not know reads as normal.
44export function priorityWord(lang, priority) {
45 return t(lang, `priority.${PRIORITY_NAMES.includes(priority) ? priority : 'medium'}`)
46}
47lib/chispa.mjs 94 lines1import { t } from './i18n.mjs'
2const C = 4
3const W = 24
4const H = 20
5const INK = '#2B2B2B'
6const PAL = { body: '#D97757', arm: '#B85F43', green: '#4ADE80', key: '#4B5058', blue: '#8AA4FF', amber: '#F5C542', card: '#FDCBB2', cardInk: '#6B2E14' }
7const CONFETTI = ['#E5484D', '#F5C542', '#4ADE80', '#60A5FA', '#D97757']
8
9// In order of priority; lib/mood.mjs picks one.
10export const MOODS = ['note', 'worried', 'celebrate', 'working', 'idle', 'sleeping']
11
12// What Chispa's image says to a screen reader, in the mod's language; an unknown mood reads as idle.
13export function moodLabel(lang, mood) {
14 return t(lang, `mood.${MOODS.includes(mood) ? mood : 'idle'}`)
15}
16
17export const TERMINAL_FACES = {
18 note: '(•ᴗ•)✎',
19 worried: '(•_•)!',
20 celebrate: '\\(•ᴗ•)/',
21 working: '(•̀ᴗ•́)⌨',
22 idle: '(•ᴗ•)',
23 sleeping: '(-_-)zᶻ',
24}
25
26const cells = (...v) => v.map(n => n * C).join(';')
27const anim = (attr, values, dur, begin = 0) => `<animate attributeName="${attr}" values="${values}" dur="${dur}s" begin="${begin}s" repeatCount="indefinite"/>`
28const shift = (values, dur) => `<animateTransform attributeName="transform" type="translate" values="${values}" dur="${dur}s" repeatCount="indefinite"/>`
29const BLINK = `<animate attributeName="height" values="${cells(2, 2, 0.5, 2)}" keyTimes="0;0.92;0.96;1" dur="3.2s" repeatCount="indefinite"/>`
30
31function px(x, y, w, h, fill, inner = '') {
32 const a = `x="${x * C}" y="${y * C}" width="${w * C}" height="${h * C}" fill="${fill}"`
33 return inner ? `<rect ${a}>${inner}</rect>` : `<rect ${a}/>`
34}
35
36function body() {
37 return [px(6, 7, 12, 8, PAL.body), px(5, 8, 1, 5, PAL.body), px(18, 8, 1, 5, PAL.body), ...[7, 10, 13, 16].map(x => px(x, 15, 1, 2, PAL.body))].join('')
38}
39
40function motion(mood) {
41 if (mood === 'idle') return shift(`0 0;0 ${-C};0 0`, 2)
42 if (mood === 'sleeping') return shift(`0 0;0 ${C / 2};0 0`, 2.4)
43 if (mood === 'celebrate') return shift(`0 0;0 ${-3 * C};0 0`, 0.5)
44 return ''
45}
46
47function eyes(mood) {
48 if (mood === 'sleeping') return px(9, 10, 2, 1, INK) + px(14, 10, 2, 1, INK)
49 return px(9, 9, 1, 2, INK, BLINK) + px(14, 9, 1, 2, INK, BLINK)
50}
51
52function mouth(mood) {
53 if (mood === 'celebrate') return px(10, 13, 4, 1, INK) + px(9, 12, 1, 1, INK) + px(14, 12, 1, 1, INK)
54 if (mood === 'worried') return px(11, 13, 2, 1, INK)
55 return ''
56}
57
58function arms(mood) {
59 return [4, 19]
60 .map((x, k) => {
61 if (mood === 'working') return px(x, 10, 1, 2, PAL.arm, anim('y', cells(10, 11, 10), 0.2, k * 0.1))
62 if (mood === 'note') return px(x, 4, 1, 4, PAL.arm)
63 if (mood === 'celebrate') return px(x, 6, 1, 2, PAL.arm)
64 return px(x, 10, 1, 2, PAL.arm)
65 })
66 .join('')
67}
68
69function extras(mood) {
70 if (mood === 'working') {
71 const bits = [0, 1, 2, 3].map(k => px(3 + k * 5, 14, 1, 1, PAL.green, anim('y', cells(14, 2), 1.2, k * 0.3) + anim('opacity', '1;0', 1.2, k * 0.3)))
72 return px(5, 15, 14, 2, PAL.key) + px(6, 15, 1, 1, PAL.green, anim('x', cells(6, 17), 1)) + bits.join('')
73 }
74 if (mood === 'note') {
75 const line = px(8, 1, 6, 1, PAL.cardInk, anim('width', cells(6, 4, 6), 2.4))
76 return px(7, 0, 10, 4, PAL.card) + line + px(8, 2, 4, 1, PAL.cardInk) + px(5, 3, 2, 1, PAL.arm) + px(17, 3, 2, 1, PAL.arm)
77 }
78 if (mood === 'sleeping') {
79 return [0, 1, 2]
80 .map(k => `<text x="${(17 + k * 1.5) * C}" y="${9 * C}" font-family="monospace" font-weight="bold" font-size="13" fill="${PAL.blue}">z${anim('y', cells(9, 1), 2.4, k * 0.8)}${anim('opacity', '1;0', 2.4, k * 0.8)}</text>`)
81 .join('')
82 }
83 if (mood === 'celebrate') {
84 return Array.from({ length: 12 }, (_, k) => px((k * 7 + 3) % W, 0, 1, 1, CONFETTI[k % 5], anim('y', cells(-1, H), 1.2 + (k % 3) * 0.3, k * 0.1))).join('')
85 }
86 if (mood === 'worried') return px(20, 1, 1, 3, PAL.amber, anim('opacity', '1;0.2;1', 0.6)) + px(20, 5, 1, 1, PAL.amber)
87 return ''
88}
89
90export function chispaSvg(mood) {
91 const m = MOODS.includes(mood) ? mood : 'idle'
92 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W * C} ${H * C}" width="${W * C}" height="${H * C}" shape-rendering="crispEdges" style="color-scheme:light dark;background:transparent"><g>${motion(m)}${body()}${arms(m)}${eyes(m)}${mouth(m)}</g>${extras(m)}</svg>`
93}
94lib/locales/en.mjs 184 lines1// English texts. Every key here is also in es.mjs (tests/i18n.test.ts checks it). Values may name params as {name}.
2export const en = {
3 'category.bug': 'bug',
4 'category.deuda': 'debt',
5 'category.test': 'test',
6 'category.aviso': 'warning',
7 'category.mejora': 'improvement',
8 'priority.high': 'urgent',
9 'priority.medium': 'normal',
10 'priority.low': 'low',
11 'band.open.one': '{n} open',
12 'band.open.other': '{n} open',
13 'mood.note': 'Chispa holding a note: loose ends to review',
14 'mood.worried': 'Chispa worried about an urgent loose end',
15 'mood.celebrate': 'Chispa celebrating a close',
16 'mood.working': 'Chispa working',
17 'mood.idle': 'Chispa watching',
18 'mood.sleeping': 'Chispa sleeping',
19 'action.view': 'View',
20 'action.review': 'Review',
21 'action.undo': 'Undo',
22 'action.do': 'Do it',
23 'action.done': 'Done',
24 'action.dismiss': 'Dismiss',
25 'action.save': 'Save',
26 'action.reject': 'Not a loose end',
27 'action.edit': 'Edit',
28 'action.editQuiet': 'edit',
29 'action.saveQuiet': 'save',
30 'action.confirmClose': 'Yes, close',
31 'action.keepOpen': 'Still open',
32 'action.push': 'Push',
33 'action.yes': 'Yes',
34 'action.gotIt': 'Got it',
35 'band.unreadable': "Can't read the loose ends",
36 'band.unreadableHint': 'The Notebook says why',
37 'band.toReview': '{n} to review',
38 'band.urgent': 'Urgent: {text}',
39 'band.closed': 'Closed: {text}',
40 'band.waiting.one': '{n} loose end awaits your OK',
41 'band.waiting.other': '{n} loose ends await your OK',
42 'band.next': 'Next: {text}',
43 'band.nextAfter': 'next: {text}',
44 'band.left.one': '{n} left',
45 'band.left.other': '{n} left',
46 'band.nothingOpen': 'Nothing left open',
47 'band.notebook': 'Notebook',
48 'band.doing': 'In progress',
49 'band.urgentLabel': 'Urgent',
50 'band.nextLabel': 'Next',
51 'band.count.high.one': '{n} urgent',
52 'band.count.high.other': '{n} urgent',
53 'band.count.medium.one': '{n} normal',
54 'band.count.medium.other': '{n} normal',
55 'band.count.low.one': '{n} low',
56 'band.count.low.other': '{n} low',
57 'band.sync.synced': 'pushed to origin',
58 'band.sync.ahead': 'not pushed',
59 'band.sync.failed': 'push failed',
60 'band.sync.local': 'only on this computer',
61 'recap.since': 'Since {since}: {parts}',
62 'recap.fresh.one': '{n} new',
63 'recap.fresh.other': '{n} new',
64 'recap.closed.one': '{n} closed in another session',
65 'recap.closed.other': '{n} closed in another session',
66 'since.recent': 'earlier today',
67 'since.yesterday': 'yesterday',
68 'since.date': '{month} {d}',
69 'since.months': 'Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec',
70 'since.unknown': 'last time',
71 'ago.now': 'just now',
72 'ago.minutes': '{n} min ago',
73 'ago.hours': '{n} h ago',
74 'ago.days': '{n} d ago',
75 'quote': '“{text}”',
76 'triage.candidate': 'Candidate loose end',
77 'triage.resolved': 'Resolved?',
78 'triage.commit': 'commit {sha}',
79 'triage.proof': 'Proof: “{quote}”',
80 'triage.saved': 'Saved in {repo}',
81 'triage.rejected': "Rejected. I won't propose things like this again.",
82 'triage.closed': 'Closed with proof',
83 'triage.kept': 'Still open.',
84 'triage.withdrawn': 'Withdrawn: resolved later',
85 'pane.unreadable': "Can't read the loose ends of {path} (refs/loose-ends): {error}. The panel recovers by itself as soon as they can be read.",
86 'pane.noRepo': 'This session is not inside a git repo.',
87 'pane.addPlaceholder': 'Note a loose end…',
88 'pane.addSubmit': 'note',
89 'pane.toReview': 'To review',
90 'pane.open': 'Open',
91 'pane.nothingOpen': 'Nothing open in this repo.',
92 'pane.closedWeek': 'Closed this week',
93 'pane.learned.one': 'The sweep learns from {n} rejection of yours',
94 'pane.learned.other': 'The sweep learns from {n} rejections of yours',
95 'pane.bornIn': 'created on {branch}',
96 'pane.doing': 'in progress',
97 'pane.stale': 'Still relevant?',
98 'pane.withdrawn': 'withdrawn',
99 'pane.withProof': 'with proof',
100 'pane.sync.synced': 'Up to date with origin',
101 'pane.sync.local': 'Only on this computer',
102 'pane.sync.ahead': 'Changes not pushed',
103 'pane.sync.failed': "Couldn't push to origin",
104 'tool.description': "Proposes a loose end: concrete work on the code that you leave undone (a bug you see, debt, a test you skip, a warning you ignore, an out-of-scope improvement). The user decides whether it is kept; it is kept in the repo's refs/loose-ends.",
105 'tool.text': 'What needs to be done, in one actionable sentence',
106 'tool.category': 'bug: something broken; debt: code that should be redone; test: a test that is missing or that you skip; warning: a warning you ignore; improvement: something worth doing that is not urgent',
107 'tool.priority': 'high: breaks something or blocks; medium: real debt; low: cosmetic',
108 'tool.evidence': 'Your literal sentence where you mention it',
109 'tool.file': 'File the loose end is anchored to: absolute path or relative to the repo root',
110 'tool.repo': "Absolute path of a file or folder of the repo the loose end belongs to, only if it is not this session's repo",
111 'tool.guide': [
112 '# Loose ends',
113 'When you leave concrete work on the code or the product undone (a bug you see, debt, a test you skip, a warning you ignore, an out-of-scope improvement), propose it with {toolId} in that same turn, with its category: bug, debt, test, warning or improvement. The user decides whether it is kept.',
114 'Do not propose checks, waits, decisions, pushes or deploys, nor tasks for the user: the mod rejects them.',
115 'If the loose end is anchored to a file, pass it in "file". If it belongs to another repo (you touched another one), pass in "repo" the absolute path of a file or folder of that repo.',
116 ].join('\n'),
117 'sweep.system': [
118 'You read the answer of a coding assistant and propose loose ends.',
119 'A loose end is ONLY concrete work on the code or the product that the assistant explicitly leaves undone: a bug or debt it saw and did not fix, something it said should change later, a test it skips, a warning it ignores.',
120 'NOT a loose end: checking or verifying something, waiting for or watching something (CI, deploys, agents), deciding something, pushing or deploying, reporting status, questions or options for the user, what it is doing now or in its next step, nor anything from the lists I give you even in other words.',
121 'Nor is it a loose end what the answer says is already being fixed or will be fixed in this session ("I launched two agents that fix it", "it goes in batch 2", "I fix it with the panel", "when they finish, suite and commit"), nor an intermediate state while an agent is still working, nor what the same sentence says is fixed or denies, nor a behavior the answer describes as decided.',
122 'Require a sentence that explicitly says something is left undone with no plan under way ("I have not touched it", "still pending", "out of scope", "not done"). Write what is really missing: if it says "X still does not work in Catalan; it is documented", the loose end is making X work in Catalan.',
123 'If there is code work behind a decision, the loose end is that work written as a task, not the decision. What is put off on purpose ("for later", "phase 5") is debt or an improvement with priority low.',
124 'When in doubt, do not propose it: returning an empty "new" is better. Return at most 2 new loose ends.',
125 'Each new loose end carries its category in "category" (bug, debt, test, warning or improvement) and in "evidence" the literal sentence of the answer where it is left undone, copied as is (at least 12 characters); without that quote it is not kept.',
126 'In "resolved" put the open loose ends and the ones in "To review" that the answer truly leaves done ("fixed", "done", "that\'s it", a commit that includes it): their id and in "quote" the literal sentence of the answer that proves it, or the literal subject of one of the "Commits of this turn" that does it.',
127 'If there is a "Candidate repos" list, fill "repo" in each new loose end with the exact path of one of those repos when the loose end clearly belongs to it; if in doubt, leave "repo" out.',
128 'Reply ONLY with JSON: {"new":[{"text":"actionable sentence in English","category":"bug|debt|test|warning|improvement","priority":"high|medium|low","evidence":"literal sentence","repo":"path of a candidate repo (optional)"}],"resolved":[{"id":"id","quote":"literal sentence"}]}.',
129 'Nothing to propose or close: {"new":[],"resolved":[]}.',
130 ].join('\n'),
131 'tool.tooShort': 'Text too short: describe the loose end in one sentence.',
132 'tool.noGit': 'This session is not in a git repo: give "repo" with the path of the loose end\'s repo.',
133 'tool.notARepo': 'The path is not inside a git repo: nothing was proposed.',
134 'tool.proposed': 'Proposed as a loose end ({id}): {text}. The user will confirm it.',
135 'tool.rejected': 'Not proposed: {reason}.',
136 'tool.unreadable': 'Could not propose: the loose ends of {target} (refs/loose-ends) cannot be read ({error}).',
137 'context.head': 'Open loose ends from earlier sessions (refs/loose-ends). Keep them in mind; do not resolve them unless asked:',
138 'context.branch': ', branch {branch}',
139 'doNow.text': 'Resolve this loose end ({id}): {text}',
140 'doNow.evidence': '\nYou mentioned it like this: “{evidence}”',
141 'suggest.prefix': 'Resolve the loose end: ',
142 'passing.text': 'Open loose ends in this file: {list}.',
143 'sweepPrompt.open': 'Open loose ends:',
144 'sweepPrompt.none': '(none)',
145 'sweepPrompt.waiting': 'To review (do not repeat them; if the answer leaves them done, they go in "resolved"):',
146 'sweepPrompt.rejected': 'Examples the user rejected (do not propose anything like them):',
147 'sweepPrompt.repos': 'Candidate repos:',
148 'sweepPrompt.commits': 'Commits of this turn:',
149 'sweepPrompt.answer': "Assistant's answer:",
150 'reject.noCategory': 'no category (bug, debt, test, warning or improvement)',
151 'reject.notCode': 'not work on the code (checking, waiting, deciding, notifying, pushing or deploying)',
152 'reject.duplicate': 'already noted',
153 'reject.sameQuote': 'repeats the quote of another loose end',
154 'reject.sameFile': 'looks like another loose end in the same file',
155 'reject.similar': 'looks like another loose end',
156 'reject.likeRejected': 'looks like one you rejected',
157 'reject.tooShort': 'text too short',
158 'push.question': "Push this repo's loose ends to origin too? They travel in refs/loose-ends, outside your branches.",
159 'push.always': 'Always',
160 'push.once': 'This time',
161 'push.never': 'Never',
162 'push.header': 'Loose ends',
163 'toast.queued': "I'll start it when Claude finishes: {text}",
164 'command.name': 'loose-ends',
165 'command.description': 'Opens the notebook: loose ends to review, open and closed',
166 'pane.title': 'Notebook',
167 'notice.imported.one': 'Imported {n} loose end from {file}. You can now delete the file from the repo.',
168 'notice.imported.other': 'Imported {n} loose ends from {file}. You can now delete the file from the repo.',
169 'idle.working.0': 'Watching in case anything is left loose',
170 'idle.working.1': 'On watch while Claude works',
171 'idle.sleeping.0': 'All tidy, taking a nap',
172 'idle.sleeping.1': 'Nothing loose, napping',
173 'idle.week.0.one': 'Clean week: {n} closed',
174 'idle.week.0.other': 'Clean week: {n} closed',
175 'idle.week.1.one': 'Nothing loose. {n} closed this week',
176 'idle.week.1.other': 'Nothing loose. {n} closed this week',
177 'idle.morning.0': 'Good morning. Nothing pending here',
178 'idle.morning.1': 'Morning! All clear',
179 'idle.afternoon.0': 'Nothing loose. Good pace',
180 'idle.afternoon.1': 'A clear afternoon, no loose ends',
181 'idle.night.0': 'Nothing pending. Good time to close the laptop',
182 'idle.night.1': 'All clear. Rest well',
183}
184