À 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…

« À 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).
The scanner writes ~/.claude/a-faire/summary.json, counts only (no title, no text):
| Key | Meaning |
|---|---|
total | open items: the « À toi : N » counter |
high | open items that block a conversation (priority now) |
updatedAt | when the scanner wrote it (ms) |
open, at | the 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, errors | detail: version 2, counts per category and priority, snoozed count, unread sources count |
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.
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.
hooks/register.mjs 183 lines1// 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}
183lib/items.mjs 353 lines1// 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}
353hooks/view.mjs 56 lines1// 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