SLOPSHOPPER

a-faire

À toi : tout ce qui attend une action que seul toi peux faire, pour que rien ne se perde. Configuration (smtp.json de la veille, mods installés pas chargés lus…

panecommandprocesstimer
v0.3.0MITupdated 2026-10-16julian-passebecq/claude-mod/mods/a-faire
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · a-faire
│ ┃ À toi ✕ › fix the failing auth test and add an audit log call │ ┃ À toi · 0 [ Rafraîchir ] │ ┃ Rien n'attend Julian. ⏺ Read(src/auth.ts) │ ┃ jamais lu ⎿ Read 6 lines │ ┃ dev ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ [ ▸ config (0) ] rien ⏺ Bash(bun test) │ ┃ [ ▸ claude_control (0) ] rien ⎿ 3 pass, 1 fail │ ┃ [ ▸ decisions (0) ] rien │ ┃ [ ▸ logins (0) ] rien ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [ ▸ secrets (0) ] rien │ ┃ [ ▸ manual (0) ] rien ✻ Worked for 42s · done 4:20 PM │ ┃ [ ▸ questions (0) ] rien │ ┃ › /afaire │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · À toi
À toi · 0 [ Rafraîchir ] Rien n'attend Julian. jamais lu dev [ ▸ config (0) ] rien [ ▸ claude_control (0) ] rien [ ▸ decisions (0) ] rien [ ▸ logins (0) ] rien [ ▸ secrets (0) ] rien [ ▸ manual (0) ] rien [ ▸ questions (0) ] rien
README

a-faire (« À toi »)

« À toi », one entry of the Mods bar: every action only Julian can do, by category (Configuration, Décisions, Connexions / 2FA, Secrets à ranger, Tests manuels, Questions des conversations), each with why it blocks, the exact steps (Copier), since when, its source, Fait and Plus tard. Read-only except its own ~/.claude/a-faire.

Claude Control (category claude_control): Julian's own list in ~/.claude/a-faire/claude-control.md (sources.claude_control.file; empty = that file in the a-faire home), one line per task - [ ] échéance · priorité · quoi · pourquoi · comment (échéance YYYY-MM-DD or —, priorité haute / moyenne / basse mapped by sources.claude_control.prios; [x] or struck = done). Sorted by deadline first (« échéance 2026-11-05 (J-19) »), then priority.

Commands: /afaire [refresh | close], and /a-toi (same) registered only when the session has no /a-toi yet (checked with $.command.list() 2 s after the session starts).

summary.json (stable shape)

The scanner writes ~/.claude/a-faire/summary.json, counts only (no title, no text):

KeyMeaning
totalopen items: the « À toi : N » counter
highopen items that block a conversation (priority now)
updatedAtwhen the scanner wrote it (ms)
open, atthe same as total and updatedAt, kept for older readers (lib/core/veille.mjs aToi() reads open, the band too)
v, by_cat, by_prio, snoozed, errorsdetail: version 2, counts per category and priority, snoozed count, unread sources count

Mods installed but not loaded

lib/mods-check.mjs lists the mod folders with lib/core/mods.mjs (listMods) and compares them with CLAUDE_CODE_PLUGIN_DIRS (splitDirs, loadedState), the same reader as Santé, Ménage and the Guide. A mod also counts as loaded when a listed folder has its name. Node only: the hooks module never imports it.

Health record

scanner/afaire_scan.mjs scan writes ~/.claude/mods-health/a-faire.json through lib/core/health.mjs.

Tests: node --test mods/a-faire/tests/*.test.mjs, claude plugin test mods/a-faire.

Source 3 files
hooks/register.mjs 183 lines
1// a-faire - the pane « À toi » (/afaire, alias /a-toi): every action only Julian can do, by category, with the exact
2// steps (Copier), since when, the source, and Fait / Plus tard. Read-only view of ~/.claude/a-faire/items.json; the
3// scanner (scanner/afaire_scan.mjs, run with node) reads the sources and writes it, and the buttons run its own
4// done / snooze commands, so there is one code path. Nothing here blocks, rewrites or adds text to a prompt, and
5// nothing calls a model. The band mod and lib/core/veille.mjs aToi() read summary.json ({ total, high, updatedAt })
6// for the red « À toi : N ».
7//   /afaire [refresh | close]   (/a-toi: the same, registered only when no other mod serves /a-toi)
8import { deepMerge } from '../lib/items.mjs'
9import { initialOpen, viewModel } from './view.mjs'
10
11const D = { cfg: null, data: null, openCats: null, busy: '', msg: '', at: 0, scanning: false, alias: false }
12const TITLE = 'À toi'
13
14const root = ($) => $.plugin.root.replace(/\\/g, '/')
15
16async function homeDir($) {
17  return ((await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || '').replace(/\\/g, '/')
18}
19
20async function ahome($) {
21  const o = await $.env.get('AFAIRE_HOME')
22  if (o) return o.replace(/\\/g, '/')
23  const h = (D.cfg && D.cfg.home) || '~/.claude/a-faire'
24  return h.replace(/^~(?=$|[\\/])/, await homeDir($)).replace(/\\/g, '/')
25}
26
27async function readJson($, p) {
28  try {
29    if (!(await $.fs.exists(p))) return null
30    return JSON.parse(String(await $.fs.read(p)).replace(/^/, ''))
31  } catch { return null }
32}
33
34async function load($) {
35  const base = (await readJson($, root($) + '/afaire.config.json')) || {}
36  D.cfg = base
37  const user = (await readJson($, (await ahome($)) + '/config.json')) || {}
38  D.cfg = deepMerge(base, user)
39  D.data = await readJson($, (await ahome($)) + '/items.json')
40  if (!D.openCats && D.data) D.openCats = initialOpen(D.cfg, D.data)
41  try { D.at = await $.clock.now() } catch { D.at = Date.now() }
42}
43
44async function runScanner($, args, timeoutMs = 90000) {
45  const node = (D.cfg && D.cfg.node) || 'node'
46  try {
47    const r = await $.process.run([node, root($) + '/scanner/afaire_scan.mjs', ...args], { timeoutMs })
48    D.msg = ((r.exitCode === 0 ? r.stdout : r.stderr || r.stdout) || '').trim().split('\n').pop().replace(/^afaire: /, '').slice(0, 200)
49    return r.exitCode === 0
50  } catch (err) {
51    D.msg = String(err && err.message ? err.message : err).slice(0, 200)
52    return false
53  }
54}
55
56async function act($, label, args) {
57  if (D.busy) return
58  D.busy = label
59  $.ui.invalidate('ui.render')
60  try { await runScanner($, args) } finally { D.busy = ''; await load($); $.ui.invalidate('ui.render') }
61}
62
63// Background refresh (session start, clock): keeps summary.json fresh for the band, skipped when recent.
64async function refresh($, force) {
65  if (D.scanning) return
66  D.scanning = true
67  try {
68    if (!D.cfg) await load($)
69    const every = Math.max(1, (D.cfg && D.cfg.refresh_min) || 5) * 60
70    await runScanner($, force ? ['scan', '--fresh'] : ['scan', '--max-age-s', String(every - 30)])
71    await load($)
72    $.ui.invalidate('ui.render')
73  } catch { /* the next tick tries again */ } finally { D.scanning = false }
74}
75
76function draw(el, cfg, vm, h) {
77  const colors = cfg.colors || {}
78  const L = cfg.labels || {}
79  const T = (text, o = {}) => el.Text({ children: [String(text ?? '')], wrap: o.wrap || 'wrap', ...(o.tone && colors[o.tone] ? { color: colors[o.tone] } : {}), ...(o.dim ? { dimColor: true } : {}), ...(o.bold ? { bold: true } : {}) })
80  const row = (parts, o = {}) => el.Box({ flexDirection: 'row', flexWrap: 'wrap', gap: 1, ...o, children: parts.filter(Boolean) })
81  const col = (parts, o = {}) => el.Box({ flexDirection: 'column', ...o, children: parts.filter(Boolean) })
82  const btn = (label, onPress, primary) => el.Button({ children: [label], variant: primary ? 'primary' : 'secondary', onPress })
83  const link = (href, label) => (href ? (el.Link ? el.Link({ href, children: [label] }) : T(`${label} : ${href}`, { dim: true })) : null)
84
85  const head = col([
86    row([T(vm.title, { bold: true, tone: vm.high ? 'now' : vm.total ? 'feature' : 'ok' }), btn(h.busy === 'refresh' ? L.scanning || 'Lecture…' : L.refresh || 'Rafraîchir', h.refresh)]),
87    T(vm.head, { dim: !vm.total }),
88    !vm.at ? T(L.never || 'jamais lu', { dim: true }) : null,
89    h.msg ? T(h.msg, { dim: true }) : null,
90  ], { marginBottom: 1 })
91
92  const item = (i) => col([
93    row([T('●', { tone: i.prio }), T(i.title, { bold: true })], { flexWrap: 'nowrap' }),
94    T(`${i.prioLabel} · ${i.why}`, { dim: true }),
95    ...(i.steps || []).map((s) => row([T(s.text), s.copy ? T(s.copy, { tone: 'feature' }) : null, s.copy ? btn(L.copy || 'Copier', (press) => h.copy(press, s.copy)) : null])),
96    row([
97      i.sinceText ? T(i.sinceText, { dim: true }) : null,
98      btn(L.done || 'Fait', () => h.done(i.id), true),
99      btn(L.later || 'Plus tard', () => h.later(i.id)),
100      i.source && i.source.href ? link(i.source.href, L.source || 'Voir la source') : T(i.source ? i.source.label : '', { dim: true }),
101    ]),
102  ], { marginLeft: 2, marginBottom: 1 })
103
104  const cats = vm.cats.map((c) => col([
105    row([
106      btn(`${c.unfolded ? '▾' : '▸'} ${c.label} (${c.n})`, () => h.toggle(c.cat)),
107      c.unfolded ? null : T(c.summary, { dim: !c.n, tone: c.n ? c.tone : undefined, wrap: 'truncate-end' }),
108    ], { flexWrap: 'nowrap' }),
109    ...(c.unfolded ? (c.items.length ? c.items.map(item) : [T(L.empty_cat || 'rien', { dim: true })]) : []),
110  ]))
111
112  const foot = col([
113    vm.snoozed ? row([T(`${L.snoozed || 'Reporté(s)'} : ${vm.snoozed}`, { dim: true }), btn(L.unsnooze || 'Tout réafficher', h.unsnooze)]) : null,
114    vm.errors.length ? T(`${L.unread || 'Sources non lues'} : ${vm.errors.join(', ')}`, { tone: 'feature' }) : null,
115  ], { marginTop: 1 })
116  return col([head, ...cats, foot], { flexGrow: 1 })
117}
118
119// /afaire [refresh | close], and /a-toi when this mod registered that alias
120async function onCommand($, e) {
121  await load($)
122  const v = String(e.args || '').trim().toLowerCase()
123  if (v === 'close') { await $.ui.close({ id: 'afaire' }); return {} }
124  await $.ui.open({ id: 'afaire', title: (D.cfg.labels || {}).title || TITLE, focus: true, closeOnEscape: true })
125  if (v === 'refresh' || !D.data) void act($, 'refresh', ['scan', '--fresh'])
126  $.ui.invalidate('ui.render')
127  return {}
128}
129
130export function register(on) {
131  on('session.start', async ($, e, next) => {
132    const r = await next(e)
133    try {
134      await $.command.register({ name: 'afaire', immediate: true, description: 'À toi : tout ce qui attend une action de toi (configuration, décisions, connexions, secrets, tests manuels, questions)', argumentHint: '[refresh | close]' })
135    } catch { /* a name clash must not break the rest */ }
136    // /a-toi: an alias of /afaire, only when the session has no /a-toi yet (a hub mod may serve it)
137    $.clock.after(2000, async () => {
138      try {
139        if ((await $.command.list()).some((c) => c.name === 'a-toi')) return
140        await $.command.register({ name: 'a-toi', immediate: true, description: 'À toi (alias de /afaire) : ce qui attend une action de toi', argumentHint: '[refresh | close]' })
141        D.alias = true
142      } catch { /* served elsewhere */ }
143    })
144    await load($).catch(() => undefined)
145    void refresh($, false)
146    const every = Math.max(1, (D.cfg && D.cfg.refresh_min) || 5) * 60000
147    $.clock.every(every, () => void refresh($, false))
148    return r
149  })
150
151  on('command.run', { command: 'afaire' }, onCommand)
152  on('command.run', { command: 'a-toi' }, async ($, e, next) => (D.alias ? onCommand($, e) : next(e))).catch(($, e, next) => next(e))
153
154  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
155    if (e.requestId !== 'afaire') return next(e)
156    const el = $.ui.resolve(e)
157    if (!D.cfg) await load($).catch(() => { D.cfg = {} })
158    const cfg = D.cfg || {}
159    const now = D.at || Date.now()
160    const vm = viewModel(cfg, D.data, D.openCats || initialOpen(cfg, D.data), now)
161    return draw(el, cfg, vm, {
162      busy: D.busy, msg: D.msg,
163      refresh: () => void act($, 'refresh', ['scan', '--fresh']),
164      done: (id) => void act($, 'done', ['done', id]),
165      later: (id) => void act($, 'later', ['snooze', id]),
166      unsnooze: () => void act($, 'unsnooze', ['unsnooze', 'all']),
167      toggle: (cat) => {
168        const cur = D.openCats || initialOpen(cfg, D.data)
169        D.openCats = cur.includes(cat) ? cur.filter((c) => c !== cat) : [...cur, cat]
170        $.ui.invalidate('ui.render')
171      },
172      copy: async (press, text) => {
173        const L = cfg.labels || {}
174        try {
175          const r = await $.ui.copy({ text, surface: press && press.surface })
176          D.msg = r && r.isCopied ? `${L.copied || 'copié'} : ${text.slice(0, 80)}` : L.copy_failed || 'copie refusée'
177        } catch { D.msg = L.copy_failed || 'copie refusée' }
178        $.ui.invalidate('ui.render')
179      },
180    })
181  })
182}
183
lib/items.mjs 353 lines
1// a-faire - pure parts (no I/O): parsers of each source, setup-gap items, done/snooze state, order and counts.
2// Tested with node --test mods/a-faire/tests/logic.test.mjs. The scanner (scanner/afaire_scan.mjs) reads the files
3// and runs gh / schtasks; this module only turns text and facts into items.
4//
5// An item: { id, src, cat, prio, title, why, steps: [{ text, copy? }], since, source: { label, href }, auto, check }
6//   cat   config | decisions | logins | secrets | manual | questions
7//   prio  now (bloque une conversation maintenant) | feature (bloque une fonction) | comfort (confort)
8//   auto  true when the scanner can tell by itself that it is done (the item then disappears with its cause)
9
10export const CATS = ['config', 'claude_control', 'decisions', 'logins', 'secrets', 'manual', 'questions']
11export const PRIOS = ['now', 'feature', 'comfort']
12
13export function deepMerge(a, b) {
14  if (!b || typeof b !== 'object' || Array.isArray(b)) return b === undefined ? a : b
15  const out = { ...(a && typeof a === 'object' && !Array.isArray(a) ? a : {}) }
16  for (const [k, v] of Object.entries(b)) if (!k.startsWith('_')) out[k] = v && typeof v === 'object' && !Array.isArray(v) ? deepMerge(out[k], v) : v
17  return out
18}
19
20export function fill(tpl, vars) {
21  return String(tpl || '').replace(/\{(\w+)\}/g, (m, k) => (vars[k] !== undefined && vars[k] !== null ? String(vars[k]) : m))
22}
23
24// FNV-1a, 32 bits, base 36: a stable id from a source and its key text.
25export function hash(s) {
26  let h = 0x811c9dc5
27  for (const ch of String(s)) { h ^= ch.codePointAt(0); h = Math.imul(h, 0x01000193) >>> 0 }
28  return h.toString(36)
29}
30
31const rx = (s, flags = 'i') => { try { return s ? new RegExp(s, flags) : null } catch { return null } }
32const clean = (s) => String(s || '').replace(/\*\*|__/g, '').replace(/\s+/g, ' ').trim()
33export const oneLine = (s, max = 110) => { const t = clean(s); return t.length > max ? t.slice(0, max - 1).trimEnd() + '…' : t }
34const dayMs = (d) => { const t = Date.parse(`${d}T09:00:00`); return Number.isFinite(t) ? t : null }
35
36// Backtick spans of a text: what a step can copy (paths, commands).
37export function codeSpans(text) {
38  return [...String(text || '').matchAll(/`([^`\n]+)`/g)].map((m) => m[1].trim()).filter(Boolean)
39}
40
41function stepsOf(text) {
42  const spans = codeSpans(text)
43  const steps = [{ text: clean(text).replace(/`/g, '') }]
44  for (const s of spans) steps.push({ text: s, copy: s })
45  return steps
46}
47
48// ---- manual-checks.md: « date · project · what · how », one line each; struck lines (~~) are done --------------------
49
50export function parseManualChecks(text, { file = '', href = '', loginRe = null, gaps = [] } = {}) {
51  const items = []
52  for (const raw of String(text || '').split(/\r?\n/)) {
53    const line = raw.replace(/^\s*[-*]\s+/, '').trim()
54    if (!/^\d{4}-\d{2}-\d{2}\s*·/.test(line)) continue
55    if (line.startsWith('~~')) continue
56    if (gaps.some((g) => g.match && g.match.test(line))) continue // a setup gap says it better (and checks it)
57    const [date, project, what, ...rest] = line.split(/\s+·\s+/)
58    const how = rest.join(' · ')
59    const cat = loginRe && loginRe.test(line) ? 'logins' : 'manual'
60    items.push({
61      id: `manual:${hash(line)}`, src: 'manual', cat, prio: cat === 'logins' ? 'feature' : 'comfort',
62      title: oneLine(`${project || ''} : ${what || ''}`),
63      why: project ? `Vérification que seul toi peux faire (${oneLine(project, 60)})` : 'Vérification que seul toi peux faire',
64      steps: how ? stepsOf(how) : [], since: dayMs(date), source: { label: file || 'manual-checks.md', href }, auto: false,
65    })
66  }
67  return items
68}
69
70// ---- claude-control.md: « - [ ] échéance · priorité · quoi · pourquoi · comment »; [x] or struck lines are done ------
71// échéance is a date (YYYY-MM-DD) or « — » (none); priorité haute | moyenne | basse (prios maps it to now | feature |
72// comfort). Items carry `due` (ms, 09:00 local) so the category sorts by deadline first.
73
74export const DEFAULT_CC_PRIOS = { haute: 'feature', high: 'feature', moyenne: 'comfort', medium: 'comfort', basse: 'comfort', low: 'comfort' }
75
76export function parseClaudeControl(text, { file = '', href = '', prios = DEFAULT_CC_PRIOS, sinceMs = null } = {}) {
77  const items = []
78  for (const raw of String(text || '').split(/\r?\n/)) {
79    const m = /^\s*[-*]\s+\[( |x|X)\]\s+(.*)$/.exec(raw)
80    if (!m || m[1] !== ' ' || /^\s*~~/.test(m[2])) continue
81    const [due = '', prio = '', what = '', why = '', ...how] = m[2].split(/\s+·\s+/)
82    if (!what.trim()) continue
83    const dueMs = /^\d{4}-\d{2}-\d{2}$/.test(due.trim()) ? dayMs(due.trim()) : null
84    const p = (prios || {})[clean(prio).toLowerCase()] || 'comfort'
85    items.push({
86      id: `cc:${hash(what.trim())}`, src: 'claude_control', cat: 'claude_control', prio: PRIOS.includes(p) ? p : 'comfort',
87      title: oneLine(what), why: oneLine(why, 220) || 'À faire pour Claude Control',
88      steps: how.length ? stepsOf(how.join(' · ')) : [], since: sinceMs, due: dueMs, dueDay: dueMs ? due.trim() : '',
89      source: { label: file || 'claude-control.md', href }, auto: false,
90    })
91  }
92  return items
93}
94
95// ---- questions.md: « date · project · question · choice · why »; only lines that say they wait for Julian ----------
96
97export function parseQuestions(text, { file = '', href = '', match = null } = {}) {
98  const re = match instanceof RegExp ? match : rx(match)
99  if (!re) return []
100  const items = []
101  for (const raw of String(text || '').split(/\r?\n/)) {
102    const line = raw.replace(/^\s*[-*]\s+/, '').replace(/^\|\s*/, '').trim()
103    if (!line || line.startsWith('~~') || line.startsWith('#') || !re.test(line)) continue
104    const parts = line.split(/\s+[·|]\s+/).map((s) => s.replace(/\|\s*$/, '').trim())
105    const isDate = /^\d{4}-\d{2}-\d{2}$/.test(parts[0])
106    const [date, project, question, choice] = isDate ? parts : ['', '', line, '']
107    items.push({
108      id: `question:${hash(line)}`, src: 'questions', cat: 'decisions', prio: 'comfort',
109      title: oneLine(`${project ? project + ' : ' : ''}${question || ''}`),
110      why: choice ? `Choix provisoire d'un agent : ${oneLine(choice, 160)}` : 'Une conversation a noté cette question pour toi',
111      steps: [], since: isDate ? dayMs(date) : null, source: { label: file || 'questions.md', href }, auto: false,
112    })
113  }
114  return items
115}
116
117// ---- DECISIONS-PENDING.md: open table rows « | n | decision | facts | R | » and « ⚠ Your action » lines ----------------
118
119export function parseDecisions(text, { file = '', href = '', stop = /already decided|resolved since|déjà décid|réglées? depuis/i, sinceMs = null } = {}) {
120  const items = []
121  let section = ''
122  for (const raw of String(text || '').split(/\r?\n/)) {
123    const h = /^#{2,3}\s+(.*)$/.exec(raw)
124    if (h) { section = clean(h[1]); if (stop && stop.test(section)) break; continue }
125    if (raw.startsWith('|')) {
126      const cells = raw.split('|').slice(1, -1).map((c) => c.trim())
127      if (cells.length < 2 || !/^\d+(\.\d+)*$/.test(cells[0])) continue
128      if (cells.slice(0, 2).some((c) => c.startsWith('~~'))) continue
129      const [num, decision, facts = '', rec = ''] = cells
130      items.push({
131        id: `decision:${hash(file + '|' + num + '|' + decision)}`, src: 'decisions', cat: 'decisions', prio: 'comfort',
132        title: oneLine(`${num} ${decision}`), why: oneLine(facts, 220) || oneLine(section, 120),
133        steps: rec ? [{ text: `Recommandé : ${clean(rec).replace(/`/g, '')}` }, ...codeSpans(rec).map((s) => ({ text: s, copy: s }))] : [],
134        since: sinceMs, source: { label: `${file || 'DECISIONS-PENDING.md'} › ${oneLine(section, 50)}`, href }, auto: false,
135      })
136      continue
137    }
138    const act = /^\s*⚠?\s*\*\*(?:Your action|Ton action|Action pour toi|À toi)\s*:?\*\*\s*:?\s*(.*)$/i.exec(raw)
139    if (act && !/^\s*~~/.test(act[1])) {
140      items.push({
141        id: `decision:${hash(file + '|action|' + act[1])}`, src: 'decisions', cat: 'decisions', prio: 'feature',
142        title: oneLine(act[1].replace(/`/g, '')), why: oneLine(section, 160), steps: codeSpans(act[1]).map((s) => ({ text: s, copy: s })),
143        since: sinceMs, source: { label: `${file || 'DECISIONS-PENDING.md'} › ${oneLine(section, 50)}`, href }, auto: false,
144      })
145    }
146  }
147  return items
148}
149
150// ---- open PRs that wait for Julian: a label, or one sentence of title/body naming Julian and approve/decide ----------
151
152export function julianSentence(text, nameRe, verbRe) {
153  for (const s of String(text || '').split(/(?<=[.!?])\s+|\n+/)) if (nameRe.test(s) && verbRe.test(s)) return clean(s)
154  return ''
155}
156
157export function prItems(prs, { labels = [], name = '\\bJulian\\b', verbs = 'approv|decid|décid|valid|tranch' } = {}) {
158  const nameRe = rx(name), verbRe = rx(verbs)
159  const want = new Set(labels.map((l) => l.toLowerCase()))
160  const items = []
161  for (const p of prs || []) {
162    const labs = (p.labels || []).map((l) => String(l.name || l).toLowerCase())
163    const label = labs.find((l) => want.has(l))
164    const sentence = nameRe && verbRe ? julianSentence(`${p.title || ''}.\n${p.body || ''}`, nameRe, verbRe) : ''
165    if (!label && !sentence) continue
166    const repo = (p.repository && (p.repository.nameWithOwner || p.repository.name)) || ''
167    const n = p.number || String(p.url || '').split('/').pop()
168    items.push({
169      id: `pr:${hash(p.url || repo + n)}`, src: 'prs', cat: 'decisions', prio: 'feature',
170      title: oneLine(`${repo.split('/').pop()}#${n} : ${p.title || ''}`),
171      why: sentence ? `La PR dit : « ${oneLine(sentence.replace(/`/g, ''), 200)} »` : `Étiquette « ${label} »`,
172      steps: p.url ? [{ text: 'Lire la PR puis approuver, demander des changements ou la fermer', copy: `gh pr view ${p.url} --web` }] : [],
173      since: p.createdAt ? Date.parse(p.createdAt) || null : null, source: { label: `${repo}#${n}`, href: p.url || '' }, auto: false,
174    })
175  }
176  return items
177}
178
179// ---- veille: open « attend Julian » alerts (questions from conversations, logins seen in their last messages) -------
180
181export function veilleItems(state, acks, { loginRe = null } = {}) {
182  const ack = new Set(acks || [])
183  const items = []
184  for (const a of Object.values((state && state.alerts) || {})) {
185    if (!a || !a.julian || a.acked || ack.has(a.key) || ack.has('*')) continue
186    const login = loginRe && loginRe.test(`${a.what || ''} ${a.title || ''}`)
187    items.push({
188      id: `veille:${hash(a.key)}`, src: 'veille', cat: login ? 'logins' : 'questions', prio: 'now',
189      title: oneLine(a.what || a.key), why: `La conversation « ${oneLine(a.title || 'sans titre', 60)} » attend ta réponse`,
190      steps: [{ text: 'Ouvrir la conversation et répondre' }, ...(a.key ? [{ text: 'puis, si besoin, dans Claude', copy: `/veille ack ${a.key}` }] : [])],
191      since: a.since || a.first_seen || null, source: { label: 'conversation', href: a.session_link || a.link || '' }, auto: false, veille_key: a.key,
192    })
193  }
194  return items
195}
196
197// ---- setup gaps: facts measured by the scanner -> items that close by themselves ------------------------------------
198// facts: { veilleHome, veilleInstalled, smtpProblem, smtpExample, watcherScript, mods: { notLoaded: [], value },
199//          task: { checked, present, lastFire, everyMin, name }, secrets: { count, registry }, now }
200
201export function smtpProblem(smtp) {
202  if (!smtp || typeof smtp !== 'object') return 'smtp.json absent'
203  for (const k of ['host', 'user', 'password', 'from', 'to']) if (!smtp[k] || /^<.*>$/.test(String(smtp[k]))) return `smtp.json : ${k} manquant`
204  return ''
205}
206
207export function gapItems(f, L = {}) {
208  const items = []
209  const now = f.now || Date.now()
210  if (f.veilleInstalled && f.smtpProblem) {
211    const target = `${f.veilleHome}/smtp.json`
212    items.push({
213      id: 'gap:smtp', src: 'gaps', cat: 'config', prio: 'feature', check: 'smtp', auto: true,
214      title: 'Créer un mot de passe d’application Gmail et remplir smtp.json de la veille',
215      why: `La veille ne peut pas t’écrire (${f.smtpProblem}) : ce qui attend Julian reste dans le panneau et se perd`,
216      steps: [
217        { text: '1. Créer un mot de passe d’application (16 lettres) sur', copy: 'https://myaccount.google.com/apppasswords' },
218        ...(f.smtpExample ? [{ text: '2. Copier le modèle (PowerShell)', copy: `Copy-Item "${f.smtpExample}" "${target}"` }] : [{ text: `2. Créer ${target}` }]),
219        { text: '3. Remplir host smtp.gmail.com, port 587, user, password (le mot de passe d’application), from, to dans', copy: target },
220        { text: '4. Tester l’envoi', copy: `node "${f.watcherScript || `${f.veilleHome}/bin/veille/watcher/veille.mjs`}" test-mail` },
221      ],
222      since: f.smtpSince || null, source: { label: target, href: '' }, match: /veille[\\/]smtp\.json|app password|mot de passe d.application/i,
223    })
224  }
225  if (f.mods && f.mods.notLoaded && f.mods.notLoaded.length) {
226    const names = f.mods.notLoaded.map((m) => m.name)
227    items.push({
228      id: 'gap:mods', src: 'gaps', cat: 'config', prio: 'comfort', check: 'mods', auto: true,
229      title: `${names.length} mod(s) installé(s) mais pas chargé(s) : ${oneLine(names.join(', '), 80)}`,
230      why: 'Leurs panneaux et commandes n’existent pas dans tes conversations',
231      steps: [
232        { text: 'Remplacer CLAUDE_CODE_PLUGIN_DIRS (bloc env de ~/.claude/settings.json) par', copy: f.mods.value },
233        { text: 'puis redémarrer les conversations (les mods se chargent au démarrage)' },
234      ],
235      since: null, source: { label: f.mods.settings || 'settings.json', href: '' },
236    })
237  }
238  if (f.task && f.task.checked && f.veilleInstalled) {
239    const every = f.task.everyMin || 5
240    const dead = f.task.present && f.task.lastFire && now - f.task.lastFire > 3 * every * 60000
241    if (!f.task.present || dead) {
242      items.push({
243        id: 'gap:task', src: 'gaps', cat: 'config', prio: 'feature', check: 'task', auto: true,
244        title: f.task.present ? `La tâche ${f.task.name} ne tourne plus` : `La tâche ${f.task.name} manque`,
245        why: 'Sans elle, rien ne surveille les conversations quand Claude est fermé (pas d’alerte, pas d’e-mail)',
246        steps: [
247          ...(f.watcherSource ? [{ text: 'Réinstaller la tâche (PowerShell)', copy: `node "${f.watcherSource}" install` }] : []),
248          { text: 'Vérifier', copy: `schtasks /Query /TN ${f.task.name} /V /FO LIST` },
249        ],
250        since: f.task.lastFire || null, source: { label: `${f.veilleHome}/heartbeat.json`, href: '' },
251      })
252    }
253  }
254  if (f.secrets && f.secrets.count > 0) {
255    items.push({
256      id: 'gap:secrets', src: 'gaps', cat: 'secrets', prio: 'feature', check: 'secrets', auto: true,
257      title: `${f.secrets.count} secret(s) de tests réels « à ranger »`,
258      why: 'Des tests réels attendent que tu ranges leurs secrets (coffre, gestionnaire de mots de passe)',
259      steps: [{ text: 'Ouvrir le registre dans Claude', copy: '/tests-reels' }],
260      since: f.secrets.since || null, source: { label: f.secrets.registry || 'registry.json', href: '' },
261    })
262  }
263  return items
264}
265
266// Entries « à ranger » in the tests-reels registry, whatever its shape (array, {items}, {secrets}, {entries}).
267export function countToFile(reg, matchRe = /ranger/i) {
268  const list = Array.isArray(reg) ? reg : reg && typeof reg === 'object' ? reg.items || reg.secrets || reg.entries || Object.values(reg) : []
269  let n = 0
270  for (const e of Array.isArray(list) ? list : []) {
271    if (!e || typeof e !== 'object') continue
272    if ([e.status, e.state, e.category, e.bucket, e.where].some((v) => v && matchRe.test(String(v)))) n++
273  }
274  return n
275}
276
277// « mods installés mais pas chargés » is measured by lib/mods-check.mjs (lib/core/mods.mjs, node only); gapItems
278// above only turns its result (facts.mods) into an item.
279
280// ---- state: done and snoozed ---------------------------------------------------------------------------------------
281
282export function applyState(items, state, now) {
283  const done = (state && state.done) || {}
284  const snooze = (state && state.snooze) || {}
285  const open = [], snoozed = [], closed = []
286  for (const it of items) {
287    if (!it.auto && done[it.id]) closed.push(it)
288    else if (snooze[it.id] && snooze[it.id] > now) snoozed.push({ ...it, until: snooze[it.id] })
289    else open.push(it)
290  }
291  return { open, snoozed, closed }
292}
293
294// Done: an auto item is re-checked (the scanner measured `items` just now): still there -> not done, say why.
295export function markDone(state, items, id, now) {
296  const it = items.find((x) => x.id === id)
297  const st = { done: { ...((state && state.done) || {}) }, snooze: { ...((state && state.snooze) || {}) }, notified: (state && state.notified) || {} }
298  if (it && it.auto) return { state: st, ok: false, msg: `pas encore fait : ${it.why}` }
299  st.done[id] = now
300  delete st.snooze[id]
301  return { state: st, ok: true, msg: it ? `fait : ${it.title}` : 'fait (ou déjà réglé)' }
302}
303
304export function markSnooze(state, id, now, days = 1) {
305  const st = { done: { ...((state && state.done) || {}) }, snooze: { ...((state && state.snooze) || {}) }, notified: (state && state.notified) || {} }
306  st.snooze[id] = now + days * 86400000
307  return { state: st, ok: true, msg: `reporté de ${days} j` }
308}
309
310// Forget done/snooze entries whose item is gone for 30 days (snoozes as soon as they expire).
311export function pruneState(state, items, now) {
312  const ids = new Set(items.map((i) => i.id))
313  const keep = (o, ttl) => Object.fromEntries(Object.entries(o || {}).filter(([k, t]) => ids.has(k) ? true : now - t < ttl))
314  return { ...state, done: keep(state && state.done, 30 * 86400000), snooze: Object.fromEntries(Object.entries((state && state.snooze) || {}).filter(([, t]) => t > now)) }
315}
316
317// ---- order, groups, counts -----------------------------------------------------------------------------------------
318
319// Items with a deadline (`due`, Claude Control) come first, earliest first; then priority, oldest, title.
320export function sortItems(items) {
321  const r = (p) => { const i = PRIOS.indexOf(p); return i < 0 ? 9 : i }
322  const d = (x) => (Number.isFinite(x.due) ? x.due : Number.MAX_SAFE_INTEGER)
323  const s = (x) => (Number.isFinite(x.since) ? x.since : Number.MAX_SAFE_INTEGER)
324  return [...items].sort((a, b) => d(a) - d(b) || r(a.prio) - r(b.prio) || s(a) - s(b) || a.title.localeCompare(b.title))
325}
326
327export function groups(items, order = CATS) {
328  return order.map((cat) => ({ cat, items: sortItems(items.filter((i) => i.cat === cat)) }))
329}
330
331// ~/.claude/a-faire/summary.json, counts only (no title, no text). The stable shape every reader relies on (README):
332//   total      open items (« À toi : N »)
333//   high       open items that block a conversation (priority now)
334//   updatedAt  when the scanner wrote it (ms)
335// Kept for older readers: open (= total, what lib/core/veille.mjs aToi() and the band read), at (= updatedAt).
336// Extra detail: v, by_cat, by_prio, snoozed, errors (number of unread sources).
337export function summary(open, snoozed, now, errors = []) {
338  const by_cat = Object.fromEntries(CATS.map((c) => [c, open.filter((i) => i.cat === c).length]))
339  const by_prio = Object.fromEntries(PRIOS.map((p) => [p, open.filter((i) => i.prio === p).length]))
340  return { v: 2, total: open.length, high: by_prio.now, updatedAt: now, open: open.length, at: now, by_cat, by_prio, snoozed: snoozed.length, errors: errors.length }
341}
342
343// The band's red counter: « À toi : N », nothing when N is 0 or unknown.
344export function counterText(sum, label = 'À toi') {
345  const n = sum && Number(sum.total ?? sum.open)
346  return n > 0 ? `${label} : ${n}` : ''
347}
348
349// Items the veille watcher should push (toast + e-mail) once: wanted priorities, not from veille itself, not seen.
350export function feedOf(open, notified, prios = ['now', 'feature']) {
351  return sortItems(open.filter((i) => i.src !== 'veille' && prios.includes(i.prio) && !(notified || {})[i.id]))
352}
353
hooks/view.mjs 56 lines
1// a-faire - view model of the pane: pure (no $, no I/O), tested with node --test.
2// Input: the merged config, ~/.claude/a-faire/items.json (written by the scanner) and which categories are unfolded.
3import { CATS, groups, oneLine } from '../lib/items.mjs'
4
5export function ago(ms) {
6  const m = Math.max(0, Math.round(ms / 60000))
7  if (m < 60) return `${m} min`
8  const h = Math.floor(m / 60)
9  return h < 48 ? `${h} h` : `${Math.floor(h / 24)} j`
10}
11
12// « échéance 2026-11-05 (J-19) », « … (aujourd’hui) », « … (dépassée) »: days counted from local midnight.
13export function dueText(i, now, L = {}) {
14  const day = 86400000
15  const n = Math.round((new Date(i.due).setHours(0, 0, 0, 0) - new Date(now).setHours(0, 0, 0, 0)) / day)
16  const when = n > 0 ? `J-${n}` : n === 0 ? L.today || 'aujourd’hui' : L.overdue || 'dépassée'
17  return `${L.due || 'échéance'} ${i.dueDay || new Date(i.due).toISOString().slice(0, 10)} (${when})`
18}
19
20// Which categories are unfolded when nothing was clicked yet: config.open = nonempty | all | none | [ids].
21export function initialOpen(cfg, data) {
22  const o = cfg.open ?? 'nonempty'
23  const order = cfg.order || CATS
24  if (Array.isArray(o)) return o
25  if (o === 'all') return [...order]
26  if (o === 'none') return []
27  const open = (data && data.open) || []
28  return order.filter((c) => open.some((i) => i.cat === c))
29}
30
31export function viewModel(cfg, data, openCats, now) {
32  const L = cfg.labels || {}
33  const names = cfg.categories || {}
34  const P = cfg.priorities || {}
35  const open = (data && data.open) || []
36  const cats = groups(open, cfg.order || CATS).map(({ cat, items }) => {
37    const top = items[0]
38    const now_n = items.filter((i) => i.prio === 'now').length
39    return {
40      cat, label: names[cat] || cat, n: items.length, unfolded: (openCats || []).includes(cat),
41      tone: now_n ? 'now' : items.some((i) => i.prio === 'feature') ? 'feature' : items.length ? 'comfort' : 'dim',
42      summary: top ? oneLine(top.title, 90) + (items.length > 1 ? ` (+${items.length - 1})` : '') : L.empty_cat || 'rien',
43      items: items.map((i) => ({ ...i, prioLabel: P[i.prio] || i.prio, sinceText: i.due ? dueText(i, now, L) : i.since ? `${L.since || 'depuis'} ${ago(now - i.since)}` : '' })),
44    }
45  })
46  const high = open.filter((i) => i.prio === 'now').length
47  return {
48    title: `${L.title || 'À toi'} · ${open.length}`,
49    head: open.length ? `${high} ${P.now || 'bloque une conversation'} · ${open.filter((i) => i.prio === 'feature').length} ${P.feature || 'bloque une fonction'} · ${open.filter((i) => i.prio === 'comfort').length} ${P.comfort || 'confort'}` : L.none || "Rien n'attend Julian.",
50    total: open.length, high, cats,
51    snoozed: ((data && data.snoozed) || []).length,
52    errors: (data && data.errors) || [],
53    at: data && data.at,
54  }
55}
56