SLOPSHOPPER

loose-ends

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…

newpanebandrowsguardcommand
★ 1v0.5.0MITupdated 2026-10-07MiguelProz/claude-loose-ends/loose-ends
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · loose-ends
│ ┃ Notebook ✕ › fix the failing auth test and add an audit log call │ ┃ This session is not inside a git repo. │ ● loose-ends: loose-ends: language en (from LANG) │ ● loose-ends: loose-ends: resolve the session repo failed (git did no │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /loose-ends │ ● loose-ends: loose-ends: resolve the session repo after the turn fai │ │ (•ᴗ•) Good morning. Nothing pending here ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
(•ᴗ•) Good morning. Nothing pending here
Pane · Notebook
This session is not inside a git repo.
README

loose-ends

The session's notebook for Claude Code, with Chispa. English and Spanish.

  • Candidates, not loose ends. What Claude leaves undone (a bug it sees, debt, a test it skips, a warning it ignores, an out-of-scope improvement) arrives as a candidate. Claude proposes it with the 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.
  • You decide. The candidate appears under the Claude message where it was born, with the sentence in bold, and 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.
  • Closing with proof. Haiku never closes anything. If an answer shows a loose end done, it proposes "Resolved?" with the sentence that proves it and, if the turn made a commit, that commit; you confirm or say "Still open". After Do it it always reads the answer, long or short.
  • Where they live. In the ref 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/).
  • The band above the prompt, with Chispa. On the desktop it is a card; the first that applies wins: the loose ends cannot be read (red border, 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.
  • Chispa carries the signal: she holds a note when there are candidates, worries about an urgent one, celebrates a close or a successful git commit, works during the turn, and only sleeps after 30 quiet minutes with nothing pending.
  • The Notebook (/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.
  • In passing. When Claude reads or edits a file with open loose ends, it is reminded of them. With an urgent loose end open, not started, with no closure proposed and no candidates to review, it is offered in gray in the empty prompt instead of Claude Code's suggestion; Tab takes it.

Requires Claude Code 2.1.287 or later (tested with 2.1.288).

Language

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.

Install

/plugin marketplace add MiguelProz/claude-loose-ends /plugin install loose-ends@claude-loose-ends /reload-plugins

Update

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

Coming from 0.3

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.

Reading the loose ends by hand

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.

Turning it off

/plugin → Installed → loose-ends. The data stays in refs/loose-ends/items of each repo.

Source 13 files
hooks/register.mjs 1052 lines
1import {
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}
1052
lib/refstore.mjs 73 lines
1// 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}
73
lib/items.mjs 247 lines
1// 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}
247
lib/filter.mjs 83 lines
1import { 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}`)
83
lib/detect.mjs 211 lines
1import { 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}
211
lib/texts.mjs 48 lines
1import { 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('; ') })
48
lib/mood.mjs 22 lines
1export 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}
22
lib/repos.mjs 140 lines
1const 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}
140
lib/screens.mjs 389 lines
1import { 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}
389
lib/i18n.mjs 47 lines
1import { 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}
47
lib/chispa.mjs 94 lines
1import { 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}
94
lib/locales/en.mjs 184 lines
1// 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