bots-garden/mini-mepublic Fork
⑂ 7eed80d
Commits
⬇ Clone ▾
git clone https://git.rickub.com/bots-garden/mini-me.git
git clone ssh://git@rickub.com/bots-garden/mini-me.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

Merge feature/log-errors into main

rickub committed 2026-09-18T15:11:14Z Browse files
7eed80d parents: 0e98cec 8de2ad8
added .claude/CLAUDE.md +80 -0
new file mode 100644
@@ -0,0 +1,80 @@
1+# Working in this sandbox
2+
3+Instructions installed by the `dev-toolkit` kit. They apply to **every** session, whatever the project.
4+
5+## Always start by reading the project memory
6+
7+Before planning, before asking the user anything, before touching a file — check for a `.memory/` directory at the repository root:
8+
9+```bash
10+ls .memory/ 2>/dev/null
11+```
12+
13+**If it exists, read it before doing anything else:**
14+
15+| Read | Why |
16+| --- | --- |
17+| `.memory/summary.md` | What the project is, its architecture, the decisions currently in force, the real build/test/run commands. |
18+| the tail of `.memory/history.md` | What has already been done, and why it was done that way. |
19+| the most recent file in `.memory/handoffs/` | Where the last session stopped: work in flight, next steps, blockers, traps. |
20+
21+Then, before you propose anything:
22+
23+- **Do not ask the user for information that is already recorded there.** Re-deriving state that is written down wastes the user's time and is the main reason this record exists.
24+- **Do not re-litigate a decision recorded in `summary.md`** without saying you are doing so and why. If you think a decision in force is wrong, say so explicitly and let the user decide.
25+- **Surface unfinished work.** If a handoff records work in flight, report it and ask whether to resume it or start something new — do not silently start something else on top of it.
26+- **Reuse the commands the memory documents.** Do not invent a parallel way to build or test a project that already has one.
27+
28+If `.memory/` does not exist yet, carry on — you will create it when you write your handoff (see below).
29+
30+## Always finish by updating the project memory
31+
32+**Every session ends with all three files below brought up to date. No exceptions** — not for a one-line fix, not for a session that only read code, not for a session that failed to achieve anything.
33+
34+You cannot detect the moment a session actually ends, so use this trigger instead: **do this as the last action before you hand control back, having finished or abandoned what the user asked for.** If the work then continues, extend what you already wrote instead of adding a second set of entries.
35+
36+Two rules govern all three files. **Match the length to the work** — a one-line fix earns a few lines, not a filled-in template; padding with "N/A" makes the record unreadable, which is the one way this mandate defeats itself. And **record what the next person cannot re-derive** — where you stopped, what you tried that failed, the trap you hit, the decision still waiting on the user. Not a diff summary; git already has that.
37+
38+### 1. `summary.md` — edit in place, never regenerate
39+
40+- **Change only the parts your session actually establishes or invalidates.** Leave the rest byte-for-byte alone. You have seen a slice of this project; the file holds what every previous session established, and rewriting it wholesale from your slice silently destroys accurate content you never looked at. This is the single biggest risk in this whole mandate.
41+- **Only from what you verified** — a command you ran, a file you read. Never a plausible guess.
42+- **If nothing about the project's state changed, leave the file untouched.** That is what "update" means; it is not an exception to the mandate.
43+- What you could not check goes under an explicit `## Not yet established` heading. A summary that states unknowns as unknown is useful; one that states guesses as fact is worse than none, because the next session will trust it.
44+
45+### 2. `history.md` — append exactly one entry
46+
47+- One dated entry per session, appended at the end. **Never rewrite or delete an existing entry**, including your own from an earlier turn.
48+- Cover: what was asked, what changed, decisions made and alternatives rejected, tests, quality gate result, docs touched — dropping the lines that have no content.
49+- A read-only or failed session gets an entry too. One line stating that, and why, is a complete entry.
50+
51+### 3. `handoffs/YYYY-MM-DD-<slug>.md` — write or update today's
52+
53+- The slug names the topic. If a file for today already covers this topic, **update it**; if today's work is a different topic, write a new file under a different slug. Never overwrite another session's handoff.
54+- **A session that achieved nothing still gets a handoff**, and it is one of the valuable ones: it stops the next agent walking into the same wall.
55+
56+### If `.memory/` does not exist
57+
58+Create the whole skeleton — `README.md`, `summary.md`, `history.md`, and `handoffs/` with your handoff in it. All four, even if your session touched one file, so a later session finds a record to read and extend rather than a bare directory. `summary.md` is then written under the same rules as above: only what you verified, the rest under `## Not yet established`. `history.md` starts with its first dated entry: this session.
59+
60+The full templates for all three files are in the `methodical-dev` skill (Phase 7). Use them as a menu, not a form to fill in.
61+
62+## Keep the memory true
63+
64+`.memory/` is **committed to the repository**, not gitignored. It only stays useful if it stays accurate, and the three files stay accurate in different ways: `summary.md` is a **snapshot** — it describes the present and carries no history. `history.md` and `handoffs/` are the **record** — they only ever grow, and are never tidied up, condensed, or corrected after the fact. A history you edit is not a history.
65+
66+`.memory/` is for whoever *continues building* the project. `docs/` is for whoever *uses* it. Keep the two apart rather than duplicating content between them.
67+
68+## Skills installed by this kit
69+
70+Prefer these over doing the same work by hand:
71+
72+| Skill | Use it for |
73+| --- | --- |
74+| `methodical-dev` | Any non-trivial change. Approval-gated: requirements → plan → implementation with tests → quality gate → EN+FR docs → `.memory/` update. Start here. |
75+| `quality` | Measuring code quality with qlty and refactoring until the gate passes. Never invoke qlty ad hoc instead. |
76+| `diataxis-doc` | Writing or restructuring documentation with the Diátaxis four-quadrant method. |
77+
78+Never make a check pass by weakening it — no new `exclude_patterns`, no lowered thresholds, no disabled plugins, no deleted or skipped tests. Fix the code, or report that you could not.
79+
80+The `quality` skill defines two narrow exceptions, both conditioned on evidence and on the user's agreement: excluding a vendored `kits/` directory (the measurement tool cannot measure itself), and removing a plugin proven to analyse nothing. Neither is a licence to trim a number — read the skill before invoking either, and never widen them.
new file mode 100644
@@ -0,0 +1,80 @@
1+# Working in this sandbox
2+
3+Instructions installed by the `dev-toolkit` kit. They apply to **every** session, whatever the project.
4+
5+## Always start by reading the project memory
6+
7+Before planning, before asking the user anything, before touching a file — check for a `.memory/` directory at the repository root:
8+
9+```bash
10+ls .memory/ 2>/dev/null
11+```
12+
13+**If it exists, read it before doing anything else:**
14+
15+| Read | Why |
16+| --- | --- |
17+| `.memory/summary.md` | What the project is, its architecture, the decisions currently in force, the real build/test/run commands. |
18+| the tail of `.memory/history.md` | What has already been done, and why it was done that way. |
19+| the most recent file in `.memory/handoffs/` | Where the last session stopped: work in flight, next steps, blockers, traps. |
20+
21+Then, before you propose anything:
22+
23+- **Do not ask the user for information that is already recorded there.** Re-deriving state that is written down wastes the user's time and is the main reason this record exists.
24+- **Do not re-litigate a decision recorded in `summary.md`** without saying you are doing so and why. If you think a decision in force is wrong, say so explicitly and let the user decide.
25+- **Surface unfinished work.** If a handoff records work in flight, report it and ask whether to resume it or start something new — do not silently start something else on top of it.
26+- **Reuse the commands the memory documents.** Do not invent a parallel way to build or test a project that already has one.
27+
28+If `.memory/` does not exist yet, carry on — you will create it when you write your handoff (see below).
29+
30+## Always finish by updating the project memory
31+
32+**Every session ends with all three files below brought up to date. No exceptions** — not for a one-line fix, not for a session that only read code, not for a session that failed to achieve anything.
33+
34+You cannot detect the moment a session actually ends, so use this trigger instead: **do this as the last action before you hand control back, having finished or abandoned what the user asked for.** If the work then continues, extend what you already wrote instead of adding a second set of entries.
35+
36+Two rules govern all three files. **Match the length to the work** — a one-line fix earns a few lines, not a filled-in template; padding with "N/A" makes the record unreadable, which is the one way this mandate defeats itself. And **record what the next person cannot re-derive** — where you stopped, what you tried that failed, the trap you hit, the decision still waiting on the user. Not a diff summary; git already has that.
37+
38+### 1. `summary.md` — edit in place, never regenerate
39+
40+- **Change only the parts your session actually establishes or invalidates.** Leave the rest byte-for-byte alone. You have seen a slice of this project; the file holds what every previous session established, and rewriting it wholesale from your slice silently destroys accurate content you never looked at. This is the single biggest risk in this whole mandate.
41+- **Only from what you verified** — a command you ran, a file you read. Never a plausible guess.
42+- **If nothing about the project's state changed, leave the file untouched.** That is what "update" means; it is not an exception to the mandate.
43+- What you could not check goes under an explicit `## Not yet established` heading. A summary that states unknowns as unknown is useful; one that states guesses as fact is worse than none, because the next session will trust it.
44+
45+### 2. `history.md` — append exactly one entry
46+
47+- One dated entry per session, appended at the end. **Never rewrite or delete an existing entry**, including your own from an earlier turn.
48+- Cover: what was asked, what changed, decisions made and alternatives rejected, tests, quality gate result, docs touched — dropping the lines that have no content.
49+- A read-only or failed session gets an entry too. One line stating that, and why, is a complete entry.
50+
51+### 3. `handoffs/YYYY-MM-DD-<slug>.md` — write or update today's
52+
53+- The slug names the topic. If a file for today already covers this topic, **update it**; if today's work is a different topic, write a new file under a different slug. Never overwrite another session's handoff.
54+- **A session that achieved nothing still gets a handoff**, and it is one of the valuable ones: it stops the next agent walking into the same wall.
55+
56+### If `.memory/` does not exist
57+
58+Create the whole skeleton — `README.md`, `summary.md`, `history.md`, and `handoffs/` with your handoff in it. All four, even if your session touched one file, so a later session finds a record to read and extend rather than a bare directory. `summary.md` is then written under the same rules as above: only what you verified, the rest under `## Not yet established`. `history.md` starts with its first dated entry: this session.
59+
60+The full templates for all three files are in the `methodical-dev` skill (Phase 7). Use them as a menu, not a form to fill in.
61+
62+## Keep the memory true
63+
64+`.memory/` is **committed to the repository**, not gitignored. It only stays useful if it stays accurate, and the three files stay accurate in different ways: `summary.md` is a **snapshot** — it describes the present and carries no history. `history.md` and `handoffs/` are the **record** — they only ever grow, and are never tidied up, condensed, or corrected after the fact. A history you edit is not a history.
65+
66+`.memory/` is for whoever *continues building* the project. `docs/` is for whoever *uses* it. Keep the two apart rather than duplicating content between them.
67+
68+## Skills installed by this kit
69+
70+Prefer these over doing the same work by hand:
71+
72+| Skill | Use it for |
73+| --- | --- |
74+| `methodical-dev` | Any non-trivial change. Approval-gated: requirements → plan → implementation with tests → quality gate → EN+FR docs → `.memory/` update. Start here. |
75+| `quality` | Measuring code quality with qlty and refactoring until the gate passes. Never invoke qlty ad hoc instead. |
76+| `diataxis-doc` | Writing or restructuring documentation with the Diátaxis four-quadrant method. |
77+
78+Never make a check pass by weakening it — no new `exclude_patterns`, no lowered thresholds, no disabled plugins, no deleted or skipped tests. Fix the code, or report that you could not.
79+
80+The `quality` skill defines two narrow exceptions, both conditioned on evidence and on the user's agreement: excluding a vendored `kits/` directory (the measurement tool cannot measure itself), and removing a plugin proven to analyse nothing. Neither is a licence to trim a number — read the skill before invoking either, and never widen them.
added .claude/skills/diataxis-doc/SKILL.md +520 -0
new file mode 100644
@@ -0,0 +1,520 @@
1+---
2+name: diataxis-doc
3+description: Document a project using the Diátaxis methodology (https://diataxis.fr). Organises documentation into four distinct quadrants — Tutorials (learning), How-to guides (solving a task), Reference (looking information up), Explanation (understanding) — along the two axes action/cognition and acquisition/application. Use when creating or restructuring a project's documentation in a rigorous, readable, user-needs-driven way.
4+---
5+# Diátaxis Documentation Skill
6+
7+## Description
8+
9+Guides the creation and restructuring of a project's documentation according to the
10+**Diátaxis** methodology (Daniele Procida — https://diataxis.fr). Diátaxis is not a rigid
11+template: it is a **compass** that guarantees every documentation page serves **exactly one
12+clearly identified user need**, and that all four fundamental needs are covered.
13+
14+The strength of the method: it solves the three problems of any documentation —
15+**content** (what to write), **style** (how to write it) and **architecture**
16+(how to organise it) — by reducing them to two simple questions.
17+
18+## When to Use
19+
20+- When a project has no documentation, or a "catch-all" documentation
21+- When existing docs mix tutorials, recipes, reference and explanation in the same pages
22+- When users "can't find" the information despite voluminous docs
23+- When you want maintainable documentation that grows in small increments
24+- After adding a feature, to document it in the right place without reorganising everything
25+
26+---
27+
28+## Core Principle: the two axes
29+
30+The whole method rests on **two orthogonal axes**. They do not merely cover the territory of
31+documentation — they **define** it. This is why there are **necessarily four quadrants**,
32+neither three nor five.
33+
34+```
35+ ACTION (practical know-how)
36+ ▲
37+ │
38+ TUTORIALS │ HOW-TO GUIDES
39+ (learning by doing) │ (achieving a goal)
40+ │
41+ ACQUISITION ────────────────────┼──────────────────── APPLICATION
42+ (studying a skill) │ (applying a skill)
43+ │
44+ EXPLANATION │ REFERENCE
45+ (understanding, thinking) │ (looking information up)
46+ │
47+ ▼
48+ COGNITION (theoretical knowledge)
49+```
50+
51+| Quadrant | Action/cognition axis | Acquisition/application axis | The user's question |
52+|----------|------------------------|-------------------------------|----------------------|
53+| **Tutorial** | Action | Acquisition | "Teach me to get started" |
54+| **How-to guide** | Action | Application | "How do I do X?" |
55+| **Reference** | Cognition | Application | "What are the exact details of X?" |
56+| **Explanation** | Cognition | Acquisition | "Can you explain why / the context?" |
57+
58+---
59+
60+## The four quadrants in detail
61+
62+### 1. TUTORIALS — learning-oriented
63+
64+A tutorial is a **guided lesson**, an experience under a teacher's direction. Its goal is NOT
65+to accomplish a real task, but to make the beginner **acquire a skill**. The teacher is
66+responsible for the student's success.
67+
68+**Writing rules (mandatory):**
69+- ✅ Announce the **destination** up front ("by the end, you will have built…"), not what will be "learned"
70+- ✅ Produce a **visible result at every step**, however small
71+- ✅ Guarantee **perfect reliability**: every command must work for everyone, every time
72+- ✅ Show the **actual expected output** ("you should see: …")
73+- ✅ Use **"we"** ("we will now…") and the direct imperative ("Type this")
74+- ✅ Flag common mistakes at the point where they can occur
75+- ❌ **Minimise explanation to the extreme** — a tutorial is NOT the place for explanation. Link to it instead.
76+- ❌ **No alternatives, no options, no digressions** — a single path to success
77+- ❌ Do not explain the "why" in detail (it breaks the learning flow)
78+
79+### 2. HOW-TO GUIDES — task-oriented
80+
81+A how-to guide is a **recipe** for reaching a specific goal. It addresses an **already
82+competent** user who knows what they want to do. "How do I deploy?", "How do I enable
83+caching?".
84+
85+> ⚠️ Key distinction from a tutorial: *"Someone who expects a recipe and receives a cooking
86+> lesson will be disappointed and annoyed."* Tutorial = lesson; guide = recipe.
87+
88+**Writing rules:**
89+- ✅ Title = **the task**, stated clearly: "How to {do X}"
90+- ✅ Start from a **real problem**, not from a feature of the tool
91+- ✅ Contain **only the steps needed** for the task
92+- ✅ Stay **adaptable to the real world** ("if you use Y, do this instead") — a guide covers variants
93+- ✅ Assume the user **already knows** what they want to accomplish
94+- ❌ Do not teach the basic concepts (that is the tutorial's job)
95+- ❌ Do not drown the recipe in explanation (link to the explanation)
96+
97+### 3. REFERENCE — information-oriented
98+
99+Reference is a **neutral, exhaustive technical description** of the machinery: the commands,
100+options, parameters, structures. It is a **map of the territory**, austere and reliable, that
101+you **consult** (you do not read it end to end).
102+
103+**Writing rules:**
104+- ✅ **Describe**, do nothing else: no instruction, no explanation, no opinion
105+- ✅ **Accuracy and completeness**: every parameter, default value, type, error
106+- ✅ **Standard, predictable structure**: the same format for every entry (tables, lists)
107+- ✅ **Mirror the product's structure**: the reference's organisation reflects that of the code
108+- ✅ Include usage **examples**, without drifting into explanation
109+- ❌ Do not teach, do not tell the "why" — link to tutorials and explanations
110+
111+> Mental model: a **nutrition label**. Factual, standardised, rigorous, with no recipe and no
112+> marketing pitch.
113+
114+### 4. EXPLANATION — understanding-oriented
115+
116+Explanation **steps back**. It answers "Can you tell me about…?". It connects concepts, gives
117+context, history, design decisions, rejected alternatives. It is the documentation you read
118+"in the bath", away from active work.
119+
120+**Writing rules:**
121+- ✅ **Make connections** between topics, take a wider view
122+- ✅ Give the **context**: history, decisions, constraints, rejected alternatives
123+- ✅ Discuss the **why**, the trade-offs, the possibilities
124+- ✅ **Allow opinion** and acknowledge multiple points of view
125+- ✅ **Stay bounded** to one topic, so it does not absorb content that belongs elsewhere
126+- ❌ Do not give step-by-step instructions (that is the guides'/tutorials' job)
127+- ❌ Do not become a reference (no exhaustive parameter lists)
128+
129+---
130+
131+## The Compass — choosing the right quadrant
132+
133+When you do not know where a piece of content belongs (or when the writing "gets stuck"),
134+answer **two questions**:
135+
136+```
137+1. Does the content inform ACTION or COGNITION?
138+ (practical steps ──vs── theoretical knowledge)
139+
140+2. Does it serve the ACQUISITION or the APPLICATION of a skill?
141+ (the user is learning ──vs── the user is doing)
142+```
143+
144+| If the content says… | …informs | …and serves | → then it is a |
145+|----------------------|----------|-------------|-----------------|
146+| "teach me by doing" | action | acquisition | **Tutorial** |
147+| "help me do this" | action | application | **How-to guide** |
148+| "give me the exact information" | cognition | application | **Reference** |
149+| "help me understand" | cognition | acquisition | **Explanation** |
150+
151+**Symptom of a misfiling:** if a page forces you to mix two styles (e.g. explaining the *why*
152+in the middle of a recipe), it contains two distinct pieces of content → **split it** and link
153+the parts to each other.
154+
155+The compass applies at every scale: a whole document, a section, or even a single sentence.
156+
157+---
158+
159+## Recommended folder architecture
160+
161+Four folders, one per quadrant, plus an index:
162+
163+```
164+docs/ (or ./new.docs, ./documentation, etc.)
165+├── README.md ← index: explains the structure and orients the reader
166+├── tutorials/ ← guided learning
167+│ └── getting-started.md
168+├── how-to/ ← task-oriented recipes
169+│ ├── do-x.md
170+│ └── do-y.md
171+├── reference/ ← exhaustive technical description
172+│ ├── cli.md
173+│ └── configuration.md
174+└── explanation/ ← understanding, context, decisions
175+ ├── architecture.md
176+ └── design-choices.md
177+```
178+
179+**Architecture rules:**
180+- ✅ **Folder names** make the quadrant obvious (tutorials / how-to / reference / explanation)
181+- ✅ The **index** (`README.md`) briefly explains the four types and links to each
182+- ✅ Documents **link to each other**: a tutorial links to the reference and the explanation rather than including them
183+- ✅ Folders may be named in the documentation's own language (`tutoriels/`, `guides-pratiques/`, …) — stay consistent
184+
185+### Bilingual architecture (the "Both" option)
186+
187+When the user wants both languages, use **one subfolder per language**, each containing its own
188+complete four-quadrant structure. A root index acts as the language selector:
189+
190+```
191+docs/
192+├── README.md ← language selector (links to fr/ and en/)
193+├── fr/
194+│ ├── README.md ← French index
195+│ ├── tutorials/
196+│ ├── how-to/
197+│ ├── reference/
198+│ └── explanation/
199+└── en/
200+ ├── README.md ← English index
201+ ├── tutorials/
202+ ├── how-to/
203+ ├── reference/
204+ └── explanation/
205+```
206+
207+**Bilingual rules:**
208+- ✅ **Cross-links stay inside a single language** (a FR doc never links to an EN doc, and vice versa)
209+- ✅ **Quadrant folder names are identical** in both languages (`tutorials/`, `how-to/`…) to keep a 1-to-1 symmetry
210+- ✅ **File names may be translated** (`premiers-pas.md` ↔ `getting-started.md`) or kept identical — stay consistent
211+- ✅ The **content** is translated, not merely copied: adapt the examples where necessary
212+- ❌ Do not mix two languages in the same file
213+- ❌ Do not duplicate only one of the two indexes — each language has its own
214+
215+> Acceptable alternative for small projects: a per-file language suffix
216+> (`getting-started-en.md` / `premiers-pas-fr.md`) in a flat structure. Reserve this for cases
217+> where creating subfolders would be disproportionate.
218+
219+---
220+
221+## Iterative workflow (the heart of the method)
222+
223+Diátaxis is **not** a grand plan to be executed in one go. It is an organic process: the
224+structure emerges from within, "one cell at a time". The documentation must always be
225+**"complete, not finished"** — useful and well-formed at every stage, while remaining open.
226+
227+**The loop, to be repeated indefinitely:**
228+
229+```
230+1. CHOOSE — take a small piece (a paragraph, a page)
231+2. ASSESS — does it serve one clear need? Is it in the right quadrant?
232+3. DECIDE — identify ONE concrete improvement
233+4. ACT — apply it and publish immediately
234+```
235+
236+**Workflow rules:**
237+- ✅ Work **small**: one improvement at a time, published right away
238+- ✅ "Every step in the right direction deserves to be published immediately"
239+- ❌ Do NOT create large empty sections "to be filled in later"
240+- ❌ Do NOT tear everything down to rebuild it all at once
241+
242+---
243+
244+## Skill application phases
245+
246+### Phase 1 — Gathering information
247+
248+**First question, MANDATORY — the language of the documentation.**
249+Before anything else, ask the user which language(s) the documentation should be produced in,
250+**via `AskUserQuestion`**, with exactly these three options:
251+
252+| Option | Effect on the output |
253+|--------|-----------------------|
254+| **French** | All documentation in French, at the root of the chosen location. |
255+| **English** | All documentation in English, at the root of the chosen location. |
256+| **Both** | Bilingual documentation: one subfolder per language (see "Bilingual architecture"). |
257+
258+This choice determines the **folder architecture** (mono- or bilingual) and the language of
259+**every** document, title and link. Never infer it from the language the user is writing to
260+you in, or from the language of the existing code and comments: ask. If documentation already
261+exists in one language and the user wants "both", add the missing language without rewriting
262+what is already there.
263+
264+> **Exception — invoked by another skill.** When this skill is invoked from another skill that
265+> has already fixed the language, do not ask: use the language it specifies and go straight to
266+> Phase 2. The `methodical-dev` skill always specifies **Both** (English + French), because it
267+> requires bilingual documentation for every change. Asking again in that case is a redundant
268+> prompt for a question that is already settled.
269+
270+Then ask (via AskUserQuestion where relevant):
271+
272+1. **Documentation target**: which project / which scope should be documented?
273+2. **Audience**: beginners discovering the tool? advanced users? contributors?
274+3. **Location**: where should the docs be written? (`docs/`, `./new.docs`, etc.) Must existing content be preserved?
275+4. **Existing material**: is there already documentation to reuse / reorganise?
276+
277+### Phase 2 — Mapping the subject matter
278+
279+Before writing, understand **factually** what is being documented:
280+- Explore the code (entry point, commands, configuration, features)
281+- List the user-facing features and the machinery (CLI, config, API…)
282+- Spot the existing documentation so as not to reinvent it
283+- Produce a list of **topics** to be filed into the four quadrants
284+
285+### Phase 3 — Filing with the Compass
286+
287+For each identified topic, apply the compass and file it into a quadrant. Build a **plan** as a
288+table:
289+
290+```
291+| Topic | Quadrant | File |
292+|-----------------------------|---------------|----------------------------------|
293+| First steps | Tutorial | tutorials/getting-started.md |
294+| Enabling feature X | How-to | how-to/enable-x.md |
295+| Configuration options | Reference | reference/configuration.md |
296+| Why this architecture | Explanation | explanation/architecture.md |
297+```
298+
299+Present this plan to the user and have it approved before writing.
300+
301+### Phase 4 — Writing quadrant by quadrant
302+
303+Write the documents while **strictly** respecting the writing rules of the corresponding
304+quadrant (see the templates below). At the end of each document, apply the quadrant's checklist.
305+
306+### Phase 5 — Index and cross-links
307+
308+- Create / update `README.md` (the index) explaining the Diátaxis structure and orienting the reader
309+- Check that documents **link to each other** in the right direction (tutorial → reference/explanation, etc.)
310+
311+### Phase 6 — Final validation
312+
313+Apply the global checklist (see below) and present the result.
314+
315+---
316+
317+## Templates
318+
319+### Template — Tutorial
320+
321+```markdown
322+# Tutorial: {what the reader will have accomplished}
323+
324+By the end of this tutorial, you will have {concrete, visible result}.
325+No prior knowledge of {X} is required.
326+
327+## Prerequisites
328+- {strictly what is required for EVERY step to work}
329+
330+## Step 1 — {first concrete action}
331+Type:
332+\`\`\`bash
333+{exact command}
334+\`\`\`
335+You should see:
336+\`\`\`
337+{actual expected output}
338+\`\`\`
339+We have just {result of the step, in one sentence}.
340+
341+## Step 2 — {next action}
342+...
343+
344+## What now?
345+You have built {recap}. To go further:
346+- To accomplish specific tasks → see the [how-to guides](../how-to/)
347+- To understand how it works → see the [explanation](../explanation/)
348+```
349+
350+### Template — How-to guide
351+
352+```markdown
353+# How to {accomplish the task}
354+
355+This guide shows how to {task}. It assumes you {skill prerequisite}.
356+
357+## Steps
358+1. {step}
359+2. {step}
360+
361+## Variants
362+- If you use {case A}, do {…} instead
363+- For {case B}, add {…}
364+
365+## See also
366+- Options reference: [reference](../reference/…)
367+- Why it works this way: [explanation](../explanation/…)
368+```
369+
370+### Template — Reference
371+
372+```markdown
373+# Reference: {component}
374+
375+> Neutral, exhaustive description of {component}.
376+
377+## {Sub-component / command / section}
378+
379+| Field / Option | Type | Default | Description |
380+|----------------|------|---------|-------------|
381+| {name} | {type} | {default} | {factual description} |
382+
383+### Example
384+\`\`\`{lang}
385+{minimal usage example}
386+\`\`\`
387+
388+### Errors
389+| Error | Cause |
390+|-------|-------|
391+| {message} | {factual cause} |
392+```
393+
394+### Template — Explanation
395+
396+```markdown
397+# {Topic} — explanation
398+
399+## What is this about?
400+{framing of the topic, overview}
401+
402+## Why it is designed this way
403+{context, constraints, decisions}
404+
405+## Rejected alternatives
406+{what was considered and why it was rejected}
407+
408+## How it relates to the rest
409+{connections with the other parts of the system}
410+```
411+
412+---
413+
414+## Checklists
415+
416+### Per-quadrant checklist
417+
418+**Tutorial:**
419+```
420+□ Is the destination announced up front?
421+□ Does every step produce a visible result?
422+□ Do all commands work without fail (perfect reliability)?
423+□ Is the actual expected output shown?
424+□ No alternatives / options / digressions?
425+□ Is explanation kept minimal (linked, not included)?
426+```
427+
428+**How-to guide:**
429+```
430+□ Does the title state a concrete task ("How to…")?
431+□ Does it start from a real problem, not a feature?
432+□ Does it contain only the necessary steps?
433+□ Does it cover real-world variants?
434+□ Does it avoid teaching the basics (assumes competence)?
435+```
436+
437+**Reference:**
438+```
439+□ Purely descriptive (no instruction, no explanation, no opinion)?
440+□ Exhaustive (every parameter, default, type, error)?
441+□ Standard, predictable format (same structure throughout)?
442+□ Mirrors the product's structure?
443+□ Contains examples without drifting into explanation?
444+```
445+
446+**Explanation:**
447+```
448+□ Does it step back and make connections?
449+□ Does it give the context (history, decisions, constraints)?
450+□ Does it discuss the why and the rejected alternatives?
451+□ Does it stay bounded (not absorbing reference/guide material)?
452+□ Is it free of step-by-step instructions?
453+```
454+
455+### Global checklist (Phase 6)
456+
457+```
458+□ Was the language (FR / EN / both) asked for — or taken from the invoking skill — and respected?
459+□ If bilingual: one subfolder per language, cross-links internal to each language?
460+□ Do all four quadrants exist (at least one document each)?
461+□ Does every document serve exactly ONE clearly identified need?
462+□ Do the folder names make the quadrant obvious?
463+□ Does the index (README) explain the structure and orient the reader?
464+□ Do documents link to each other (no duplication)?
465+□ Are there no empty "to be filled in later" sections?
466+□ Is every document "complete, not finished" (useful right now)?
467+```
468+
469+---
470+
471+## Markdown formatting
472+
473+Write Markdown for readers who soft-wrap in their editor, not for a fixed column
474+width. **Do not hard-wrap prose**: never insert a line break in the middle of a
475+sentence or paragraph.
476+
477+- **One logical line per block.** A paragraph is a single line. A list item is a
478+ single line (its continuation text stays on the same line, not wrapped under
479+ the bullet). A blockquote paragraph is a single `>` line.
480+- **Blank lines separate blocks** — keep exactly one blank line between
481+ paragraphs, list items groups, headings, and code fences.
482+- **Leave code, tables, and headings alone.** Fenced code blocks keep their
483+ internal newlines verbatim; table rows stay one row per line; a heading is its
484+ own line.
485+
486+This keeps diffs meaningful (a reworded sentence changes one line, not a whole
487+reflowed block) and matches how the rendered output looks anyway. If you are
488+editing a file that was previously hard-wrapped, unwrap the blocks you touch.
489+
490+## Strict Rules
491+
492+**You must NEVER:**
493+- ❌ Mix two quadrants in the same document (recipe + explanation, reference + tutorial…)
494+- ❌ Put detailed explanation into a tutorial or a how-to guide
495+- ❌ Put step-by-step instructions into a reference or an explanation
496+- ❌ Create empty folders / sections in anticipation
497+- ❌ Brutally reorganise all the existing documentation at once (work in increments)
498+- ❌ Duplicate content across quadrants instead of linking
499+- ❌ Hard-wrap prose — no line break in the middle of a sentence or paragraph (see "Markdown formatting")
500+
501+**You must ALWAYS:**
502+- ✅ File every piece of content with the compass (action/cognition × acquisition/application)
503+- ✅ Respect the writing rules specific to the quadrant
504+- ✅ Guarantee absolute reliability in tutorials (every command mentally tested)
505+- ✅ Keep the reference neutral, exhaustive and structured
506+- ✅ Link documents to each other rather than merging them
507+- ✅ Work in small publishable increments ("complete, not finished")
508+- ✅ Write each paragraph, list item, and blockquote as a single unwrapped line
509+
510+---
511+
512+## Notes
513+
514+Diátaxis imposes no technology (Markdown, Sphinx, MkDocs, Docusaurus… it does not matter). What
515+counts is the **clean separation of the four needs**. Documentation that respects this
516+separation is easier to write, to maintain and — above all — to use: every reader immediately
517+knows where to go depending on whether they want to **learn**, **do**, **look up** or
518+**understand**.
519+
520+Canonical reference: https://diataxis.fr
new file mode 100644
@@ -0,0 +1,520 @@
1+---
2+name: diataxis-doc
3+description: Document a project using the Diátaxis methodology (https://diataxis.fr). Organises documentation into four distinct quadrants — Tutorials (learning), How-to guides (solving a task), Reference (looking information up), Explanation (understanding) — along the two axes action/cognition and acquisition/application. Use when creating or restructuring a project's documentation in a rigorous, readable, user-needs-driven way.
4+---
5+# Diátaxis Documentation Skill
6+
7+## Description
8+
9+Guides the creation and restructuring of a project's documentation according to the
10+**Diátaxis** methodology (Daniele Procida — https://diataxis.fr). Diátaxis is not a rigid
11+template: it is a **compass** that guarantees every documentation page serves **exactly one
12+clearly identified user need**, and that all four fundamental needs are covered.
13+
14+The strength of the method: it solves the three problems of any documentation —
15+**content** (what to write), **style** (how to write it) and **architecture**
16+(how to organise it) — by reducing them to two simple questions.
17+
18+## When to Use
19+
20+- When a project has no documentation, or a "catch-all" documentation
21+- When existing docs mix tutorials, recipes, reference and explanation in the same pages
22+- When users "can't find" the information despite voluminous docs
23+- When you want maintainable documentation that grows in small increments
24+- After adding a feature, to document it in the right place without reorganising everything
25+
26+---
27+
28+## Core Principle: the two axes
29+
30+The whole method rests on **two orthogonal axes**. They do not merely cover the territory of
31+documentation — they **define** it. This is why there are **necessarily four quadrants**,
32+neither three nor five.
33+
34+```
35+ ACTION (practical know-how)
36+ ▲
37+ │
38+ TUTORIALS │ HOW-TO GUIDES
39+ (learning by doing) │ (achieving a goal)
40+ │
41+ ACQUISITION ────────────────────┼──────────────────── APPLICATION
42+ (studying a skill) │ (applying a skill)
43+ │
44+ EXPLANATION │ REFERENCE
45+ (understanding, thinking) │ (looking information up)
46+ │
47+ ▼
48+ COGNITION (theoretical knowledge)
49+```
50+
51+| Quadrant | Action/cognition axis | Acquisition/application axis | The user's question |
52+|----------|------------------------|-------------------------------|----------------------|
53+| **Tutorial** | Action | Acquisition | "Teach me to get started" |
54+| **How-to guide** | Action | Application | "How do I do X?" |
55+| **Reference** | Cognition | Application | "What are the exact details of X?" |
56+| **Explanation** | Cognition | Acquisition | "Can you explain why / the context?" |
57+
58+---
59+
60+## The four quadrants in detail
61+
62+### 1. TUTORIALS — learning-oriented
63+
64+A tutorial is a **guided lesson**, an experience under a teacher's direction. Its goal is NOT
65+to accomplish a real task, but to make the beginner **acquire a skill**. The teacher is
66+responsible for the student's success.
67+
68+**Writing rules (mandatory):**
69+- ✅ Announce the **destination** up front ("by the end, you will have built…"), not what will be "learned"
70+- ✅ Produce a **visible result at every step**, however small
71+- ✅ Guarantee **perfect reliability**: every command must work for everyone, every time
72+- ✅ Show the **actual expected output** ("you should see: …")
73+- ✅ Use **"we"** ("we will now…") and the direct imperative ("Type this")
74+- ✅ Flag common mistakes at the point where they can occur
75+- ❌ **Minimise explanation to the extreme** — a tutorial is NOT the place for explanation. Link to it instead.
76+- ❌ **No alternatives, no options, no digressions** — a single path to success
77+- ❌ Do not explain the "why" in detail (it breaks the learning flow)
78+
79+### 2. HOW-TO GUIDES — task-oriented
80+
81+A how-to guide is a **recipe** for reaching a specific goal. It addresses an **already
82+competent** user who knows what they want to do. "How do I deploy?", "How do I enable
83+caching?".
84+
85+> ⚠️ Key distinction from a tutorial: *"Someone who expects a recipe and receives a cooking
86+> lesson will be disappointed and annoyed."* Tutorial = lesson; guide = recipe.
87+
88+**Writing rules:**
89+- ✅ Title = **the task**, stated clearly: "How to {do X}"
90+- ✅ Start from a **real problem**, not from a feature of the tool
91+- ✅ Contain **only the steps needed** for the task
92+- ✅ Stay **adaptable to the real world** ("if you use Y, do this instead") — a guide covers variants
93+- ✅ Assume the user **already knows** what they want to accomplish
94+- ❌ Do not teach the basic concepts (that is the tutorial's job)
95+- ❌ Do not drown the recipe in explanation (link to the explanation)
96+
97+### 3. REFERENCE — information-oriented
98+
99+Reference is a **neutral, exhaustive technical description** of the machinery: the commands,
100+options, parameters, structures. It is a **map of the territory**, austere and reliable, that
101+you **consult** (you do not read it end to end).
102+
103+**Writing rules:**
104+- ✅ **Describe**, do nothing else: no instruction, no explanation, no opinion
105+- ✅ **Accuracy and completeness**: every parameter, default value, type, error
106+- ✅ **Standard, predictable structure**: the same format for every entry (tables, lists)
107+- ✅ **Mirror the product's structure**: the reference's organisation reflects that of the code
108+- ✅ Include usage **examples**, without drifting into explanation
109+- ❌ Do not teach, do not tell the "why" — link to tutorials and explanations
110+
111+> Mental model: a **nutrition label**. Factual, standardised, rigorous, with no recipe and no
112+> marketing pitch.
113+
114+### 4. EXPLANATION — understanding-oriented
115+
116+Explanation **steps back**. It answers "Can you tell me about…?". It connects concepts, gives
117+context, history, design decisions, rejected alternatives. It is the documentation you read
118+"in the bath", away from active work.
119+
120+**Writing rules:**
121+- ✅ **Make connections** between topics, take a wider view
122+- ✅ Give the **context**: history, decisions, constraints, rejected alternatives
123+- ✅ Discuss the **why**, the trade-offs, the possibilities
124+- ✅ **Allow opinion** and acknowledge multiple points of view
125+- ✅ **Stay bounded** to one topic, so it does not absorb content that belongs elsewhere
126+- ❌ Do not give step-by-step instructions (that is the guides'/tutorials' job)
127+- ❌ Do not become a reference (no exhaustive parameter lists)
128+
129+---
130+
131+## The Compass — choosing the right quadrant
132+
133+When you do not know where a piece of content belongs (or when the writing "gets stuck"),
134+answer **two questions**:
135+
136+```
137+1. Does the content inform ACTION or COGNITION?
138+ (practical steps ──vs── theoretical knowledge)
139+
140+2. Does it serve the ACQUISITION or the APPLICATION of a skill?
141+ (the user is learning ──vs── the user is doing)
142+```
143+
144+| If the content says… | …informs | …and serves | → then it is a |
145+|----------------------|----------|-------------|-----------------|
146+| "teach me by doing" | action | acquisition | **Tutorial** |
147+| "help me do this" | action | application | **How-to guide** |
148+| "give me the exact information" | cognition | application | **Reference** |
149+| "help me understand" | cognition | acquisition | **Explanation** |
150+
151+**Symptom of a misfiling:** if a page forces you to mix two styles (e.g. explaining the *why*
152+in the middle of a recipe), it contains two distinct pieces of content → **split it** and link
153+the parts to each other.
154+
155+The compass applies at every scale: a whole document, a section, or even a single sentence.
156+
157+---
158+
159+## Recommended folder architecture
160+
161+Four folders, one per quadrant, plus an index:
162+
163+```
164+docs/ (or ./new.docs, ./documentation, etc.)
165+├── README.md ← index: explains the structure and orients the reader
166+├── tutorials/ ← guided learning
167+│ └── getting-started.md
168+├── how-to/ ← task-oriented recipes
169+│ ├── do-x.md
170+│ └── do-y.md
171+├── reference/ ← exhaustive technical description
172+│ ├── cli.md
173+│ └── configuration.md
174+└── explanation/ ← understanding, context, decisions
175+ ├── architecture.md
176+ └── design-choices.md
177+```
178+
179+**Architecture rules:**
180+- ✅ **Folder names** make the quadrant obvious (tutorials / how-to / reference / explanation)
181+- ✅ The **index** (`README.md`) briefly explains the four types and links to each
182+- ✅ Documents **link to each other**: a tutorial links to the reference and the explanation rather than including them
183+- ✅ Folders may be named in the documentation's own language (`tutoriels/`, `guides-pratiques/`, …) — stay consistent
184+
185+### Bilingual architecture (the "Both" option)
186+
187+When the user wants both languages, use **one subfolder per language**, each containing its own
188+complete four-quadrant structure. A root index acts as the language selector:
189+
190+```
191+docs/
192+├── README.md ← language selector (links to fr/ and en/)
193+├── fr/
194+│ ├── README.md ← French index
195+│ ├── tutorials/
196+│ ├── how-to/
197+│ ├── reference/
198+│ └── explanation/
199+└── en/
200+ ├── README.md ← English index
201+ ├── tutorials/
202+ ├── how-to/
203+ ├── reference/
204+ └── explanation/
205+```
206+
207+**Bilingual rules:**
208+- ✅ **Cross-links stay inside a single language** (a FR doc never links to an EN doc, and vice versa)
209+- ✅ **Quadrant folder names are identical** in both languages (`tutorials/`, `how-to/`…) to keep a 1-to-1 symmetry
210+- ✅ **File names may be translated** (`premiers-pas.md` ↔ `getting-started.md`) or kept identical — stay consistent
211+- ✅ The **content** is translated, not merely copied: adapt the examples where necessary
212+- ❌ Do not mix two languages in the same file
213+- ❌ Do not duplicate only one of the two indexes — each language has its own
214+
215+> Acceptable alternative for small projects: a per-file language suffix
216+> (`getting-started-en.md` / `premiers-pas-fr.md`) in a flat structure. Reserve this for cases
217+> where creating subfolders would be disproportionate.
218+
219+---
220+
221+## Iterative workflow (the heart of the method)
222+
223+Diátaxis is **not** a grand plan to be executed in one go. It is an organic process: the
224+structure emerges from within, "one cell at a time". The documentation must always be
225+**"complete, not finished"** — useful and well-formed at every stage, while remaining open.
226+
227+**The loop, to be repeated indefinitely:**
228+
229+```
230+1. CHOOSE — take a small piece (a paragraph, a page)
231+2. ASSESS — does it serve one clear need? Is it in the right quadrant?
232+3. DECIDE — identify ONE concrete improvement
233+4. ACT — apply it and publish immediately
234+```
235+
236+**Workflow rules:**
237+- ✅ Work **small**: one improvement at a time, published right away
238+- ✅ "Every step in the right direction deserves to be published immediately"
239+- ❌ Do NOT create large empty sections "to be filled in later"
240+- ❌ Do NOT tear everything down to rebuild it all at once
241+
242+---
243+
244+## Skill application phases
245+
246+### Phase 1 — Gathering information
247+
248+**First question, MANDATORY — the language of the documentation.**
249+Before anything else, ask the user which language(s) the documentation should be produced in,
250+**via `AskUserQuestion`**, with exactly these three options:
251+
252+| Option | Effect on the output |
253+|--------|-----------------------|
254+| **French** | All documentation in French, at the root of the chosen location. |
255+| **English** | All documentation in English, at the root of the chosen location. |
256+| **Both** | Bilingual documentation: one subfolder per language (see "Bilingual architecture"). |
257+
258+This choice determines the **folder architecture** (mono- or bilingual) and the language of
259+**every** document, title and link. Never infer it from the language the user is writing to
260+you in, or from the language of the existing code and comments: ask. If documentation already
261+exists in one language and the user wants "both", add the missing language without rewriting
262+what is already there.
263+
264+> **Exception — invoked by another skill.** When this skill is invoked from another skill that
265+> has already fixed the language, do not ask: use the language it specifies and go straight to
266+> Phase 2. The `methodical-dev` skill always specifies **Both** (English + French), because it
267+> requires bilingual documentation for every change. Asking again in that case is a redundant
268+> prompt for a question that is already settled.
269+
270+Then ask (via AskUserQuestion where relevant):
271+
272+1. **Documentation target**: which project / which scope should be documented?
273+2. **Audience**: beginners discovering the tool? advanced users? contributors?
274+3. **Location**: where should the docs be written? (`docs/`, `./new.docs`, etc.) Must existing content be preserved?
275+4. **Existing material**: is there already documentation to reuse / reorganise?
276+
277+### Phase 2 — Mapping the subject matter
278+
279+Before writing, understand **factually** what is being documented:
280+- Explore the code (entry point, commands, configuration, features)
281+- List the user-facing features and the machinery (CLI, config, API…)
282+- Spot the existing documentation so as not to reinvent it
283+- Produce a list of **topics** to be filed into the four quadrants
284+
285+### Phase 3 — Filing with the Compass
286+
287+For each identified topic, apply the compass and file it into a quadrant. Build a **plan** as a
288+table:
289+
290+```
291+| Topic | Quadrant | File |
292+|-----------------------------|---------------|----------------------------------|
293+| First steps | Tutorial | tutorials/getting-started.md |
294+| Enabling feature X | How-to | how-to/enable-x.md |
295+| Configuration options | Reference | reference/configuration.md |
296+| Why this architecture | Explanation | explanation/architecture.md |
297+```
298+
299+Present this plan to the user and have it approved before writing.
300+
301+### Phase 4 — Writing quadrant by quadrant
302+
303+Write the documents while **strictly** respecting the writing rules of the corresponding
304+quadrant (see the templates below). At the end of each document, apply the quadrant's checklist.
305+
306+### Phase 5 — Index and cross-links
307+
308+- Create / update `README.md` (the index) explaining the Diátaxis structure and orienting the reader
309+- Check that documents **link to each other** in the right direction (tutorial → reference/explanation, etc.)
310+
311+### Phase 6 — Final validation
312+
313+Apply the global checklist (see below) and present the result.
314+
315+---
316+
317+## Templates
318+
319+### Template — Tutorial
320+
321+```markdown
322+# Tutorial: {what the reader will have accomplished}
323+
324+By the end of this tutorial, you will have {concrete, visible result}.
325+No prior knowledge of {X} is required.
326+
327+## Prerequisites
328+- {strictly what is required for EVERY step to work}
329+
330+## Step 1 — {first concrete action}
331+Type:
332+\`\`\`bash
333+{exact command}
334+\`\`\`
335+You should see:
336+\`\`\`
337+{actual expected output}
338+\`\`\`
339+We have just {result of the step, in one sentence}.
340+
341+## Step 2 — {next action}
342+...
343+
344+## What now?
345+You have built {recap}. To go further:
346+- To accomplish specific tasks → see the [how-to guides](../how-to/)
347+- To understand how it works → see the [explanation](../explanation/)
348+```
349+
350+### Template — How-to guide
351+
352+```markdown
353+# How to {accomplish the task}
354+
355+This guide shows how to {task}. It assumes you {skill prerequisite}.
356+
357+## Steps
358+1. {step}
359+2. {step}
360+
361+## Variants
362+- If you use {case A}, do {…} instead
363+- For {case B}, add {…}
364+
365+## See also
366+- Options reference: [reference](../reference/…)
367+- Why it works this way: [explanation](../explanation/…)
368+```
369+
370+### Template — Reference
371+
372+```markdown
373+# Reference: {component}
374+
375+> Neutral, exhaustive description of {component}.
376+
377+## {Sub-component / command / section}
378+
379+| Field / Option | Type | Default | Description |
380+|----------------|------|---------|-------------|
381+| {name} | {type} | {default} | {factual description} |
382+
383+### Example
384+\`\`\`{lang}
385+{minimal usage example}
386+\`\`\`
387+
388+### Errors
389+| Error | Cause |
390+|-------|-------|
391+| {message} | {factual cause} |
392+```
393+
394+### Template — Explanation
395+
396+```markdown
397+# {Topic} — explanation
398+
399+## What is this about?
400+{framing of the topic, overview}
401+
402+## Why it is designed this way
403+{context, constraints, decisions}
404+
405+## Rejected alternatives
406+{what was considered and why it was rejected}
407+
408+## How it relates to the rest
409+{connections with the other parts of the system}
410+```
411+
412+---
413+
414+## Checklists
415+
416+### Per-quadrant checklist
417+
418+**Tutorial:**
419+```
420+□ Is the destination announced up front?
421+□ Does every step produce a visible result?
422+□ Do all commands work without fail (perfect reliability)?
423+□ Is the actual expected output shown?
424+□ No alternatives / options / digressions?
425+□ Is explanation kept minimal (linked, not included)?
426+```
427+
428+**How-to guide:**
429+```
430+□ Does the title state a concrete task ("How to…")?
431+□ Does it start from a real problem, not a feature?
432+□ Does it contain only the necessary steps?
433+□ Does it cover real-world variants?
434+□ Does it avoid teaching the basics (assumes competence)?
435+```
436+
437+**Reference:**
438+```
439+□ Purely descriptive (no instruction, no explanation, no opinion)?
440+□ Exhaustive (every parameter, default, type, error)?
441+□ Standard, predictable format (same structure throughout)?
442+□ Mirrors the product's structure?
443+□ Contains examples without drifting into explanation?
444+```
445+
446+**Explanation:**
447+```
448+□ Does it step back and make connections?
449+□ Does it give the context (history, decisions, constraints)?
450+□ Does it discuss the why and the rejected alternatives?
451+□ Does it stay bounded (not absorbing reference/guide material)?
452+□ Is it free of step-by-step instructions?
453+```
454+
455+### Global checklist (Phase 6)
456+
457+```
458+□ Was the language (FR / EN / both) asked for — or taken from the invoking skill — and respected?
459+□ If bilingual: one subfolder per language, cross-links internal to each language?
460+□ Do all four quadrants exist (at least one document each)?
461+□ Does every document serve exactly ONE clearly identified need?
462+□ Do the folder names make the quadrant obvious?
463+□ Does the index (README) explain the structure and orient the reader?
464+□ Do documents link to each other (no duplication)?
465+□ Are there no empty "to be filled in later" sections?
466+□ Is every document "complete, not finished" (useful right now)?
467+```
468+
469+---
470+
471+## Markdown formatting
472+
473+Write Markdown for readers who soft-wrap in their editor, not for a fixed column
474+width. **Do not hard-wrap prose**: never insert a line break in the middle of a
475+sentence or paragraph.
476+
477+- **One logical line per block.** A paragraph is a single line. A list item is a
478+ single line (its continuation text stays on the same line, not wrapped under
479+ the bullet). A blockquote paragraph is a single `>` line.
480+- **Blank lines separate blocks** — keep exactly one blank line between
481+ paragraphs, list items groups, headings, and code fences.
482+- **Leave code, tables, and headings alone.** Fenced code blocks keep their
483+ internal newlines verbatim; table rows stay one row per line; a heading is its
484+ own line.
485+
486+This keeps diffs meaningful (a reworded sentence changes one line, not a whole
487+reflowed block) and matches how the rendered output looks anyway. If you are
488+editing a file that was previously hard-wrapped, unwrap the blocks you touch.
489+
490+## Strict Rules
491+
492+**You must NEVER:**
493+- ❌ Mix two quadrants in the same document (recipe + explanation, reference + tutorial…)
494+- ❌ Put detailed explanation into a tutorial or a how-to guide
495+- ❌ Put step-by-step instructions into a reference or an explanation
496+- ❌ Create empty folders / sections in anticipation
497+- ❌ Brutally reorganise all the existing documentation at once (work in increments)
498+- ❌ Duplicate content across quadrants instead of linking
499+- ❌ Hard-wrap prose — no line break in the middle of a sentence or paragraph (see "Markdown formatting")
500+
501+**You must ALWAYS:**
502+- ✅ File every piece of content with the compass (action/cognition × acquisition/application)
503+- ✅ Respect the writing rules specific to the quadrant
504+- ✅ Guarantee absolute reliability in tutorials (every command mentally tested)
505+- ✅ Keep the reference neutral, exhaustive and structured
506+- ✅ Link documents to each other rather than merging them
507+- ✅ Work in small publishable increments ("complete, not finished")
508+- ✅ Write each paragraph, list item, and blockquote as a single unwrapped line
509+
510+---
511+
512+## Notes
513+
514+Diátaxis imposes no technology (Markdown, Sphinx, MkDocs, Docusaurus… it does not matter). What
515+counts is the **clean separation of the four needs**. Documentation that respects this
516+separation is easier to write, to maintain and — above all — to use: every reader immediately
517+knows where to go depending on whether they want to **learn**, **do**, **look up** or
518+**understand**.
519+
520+Canonical reference: https://diataxis.fr
added .claude/skills/ebook-builder/SKILL.md +110 -0
new file mode 100644
@@ -0,0 +1,110 @@
1+---
2+name: ebook-builder
3+description: Create, generate and update an AsciiDoc e-book from a per-chapter project (one directory per chapter). Produces one chapter file per project chapter, a master document, diagrams, and builds to HTML/PDF. Use when asked to build/refresh an e-book or PDF book from a step-by-step project.
4+---
5+
6+# ebook-builder
7+
8+Turn a project organised as **one directory per chapter** (`01-…/`, `02-…/`, each with a `README.md`) into a coherent **AsciiDoc e-book** that builds to **HTML and PDF**. The book is **curated and synthesised** from the chapters — not a raw concatenation of READMEs: it reads as a narrative with a consistent voice.
9+
10+## When to use
11+
12+- "Make an e-book / a PDF book out of this project."
13+- "Update the book after I changed / added a chapter."
14+
15+## Dependencies
16+
17+Nothing local is required if Docker is available (the build script uses the official `asciidoctor/docker-asciidoctor` image). To build without Docker: `gem install asciidoctor asciidoctor-pdf rouge`.
18+
19+## Layout
20+
21+```text
22+ebook/
23+├── book.adoc ← master document: attributes + include::chapters/*
24+├── chapters/
25+│ ├── 00-introduction.adoc
26+│ ├── 01-….adoc ← one per project chapter (curated from its README)
27+│ └── 99-conclusion.adoc
28+├── images/
29+│ ├── *.drawio ← editable diagram sources
30+│ └── *.svg ← exported diagrams used by the book
31+├── Makefile ← optional convenience (pdf/html/docker-pdf)
32+├── README.adoc ← how to build/edit
33+└── build/ ← output (git-ignored)
34+```
35+
36+## The master `book.adoc`
37+
38+Standard AsciiDoc book attributes, then one `include::` per chapter in order:
39+
40+```asciidoc
41+= Book title
42+:doctype: book
43+:toc: left
44+:toclevels: 3
45+:sectnums:
46+:icons: font
47+:source-highlighter: rouge
48+:imagesdir: images
49+
50+[preface]
51+== Foreword
52+…
53+
54+include::chapters/00-introduction.adoc[]
55+include::chapters/01-….adoc[]
56+…
57+include::chapters/99-conclusion.adoc[]
58+```
59+
60+## Workflow
61+
62+### 1. Read the project
63+
64+List the chapter directories and read each `README.md`. Note, per chapter: the need it answers, the key config/code, the diagrams, the "what you retain".
65+
66+### 2. Scaffold (only if `ebook/` doesn't exist)
67+
68+Create the layout above: `book.adoc`, `chapters/`, `images/`, and (optionally) a `Makefile` + `README.adoc`. Add an `00-introduction` (hook + how the book is built) and a `99-conclusion`.
69+
70+### 3. Write one chapter file per project chapter
71+
72+Each `chapters/NN-….adoc` is **synthesised** from the matching README, in the book's voice. A reliable per-chapter shape:
73+
74+- `== <chapter title>`
75+- `=== À quoi on répond` — the need.
76+- `=== La configuration` / the key code, in a `[source,<lang>]` block (colourised via rouge).
77+- `=== Pourquoi` — the explanation.
78+- optional `=== Prompts d'essai` / a diagram (`image::name.svg[…]`).
79+- `=== Ce qu'on retient` — a short bullet recap.
80+
81+**Never hard-wrap prose in the `.adoc` files.** Write each paragraph, and each list item, as **one single physical line** — never insert a line break in the middle of a sentence to fit a column width. Let the editor soft-wrap. This keeps diffs meaningful (a reworded sentence touches one line) and avoids stray breaks. Leave code blocks (`----`), tables (`|===`), headings, block macros (`image::`), and admonition delimiters (`====`) as they are — the rule is about *prose*, not block structure. When editing a chapter that was hard-wrapped, unwrap the paragraphs you touch.
82+
83+### 4. Diagrams
84+
85+Author diagrams as `.drawio` (editable) + export `.svg` (used by the book), kept in `images/`. Hand-authored SVGs are fine too. After writing, sanity-check the XML parses.
86+
87+### 5. Build
88+
89+```bash
90+bash .claude/skills/ebook-builder/scripts/build_ebook.sh ebook # html + pdf
91+bash .claude/skills/ebook-builder/scripts/build_ebook.sh ebook html # html only
92+```
93+
94+Confirm it builds with no errors and the diagrams appear.
95+
96+### 6. Update
97+
98+- Chapter changed → update its `.adoc`.
99+- Chapter added → add `chapters/NN-….adoc` **and** an `include::` line in `book.adoc` (in order).
100+- Chapter removed → delete the file and its `include::`.
101+- Re-build to verify.
102+
103+## Rules
104+
105+- **Synthesise, don't concatenate.** The book is a narrative, not a README dump.
106+- **Keep chapter order** aligned with the numbered directories.
107+- **Colourise code** with `[source,<lang>]` blocks.
108+- **Diagrams as drawio + svg**, referenced from the chapters.
109+- **Don't hard-wrap prose (hard rule).** One single physical line per paragraph and per list item in the `.adoc` files — no mid-sentence line breaks. Only block structure (code, tables, headings, macros, `====`) keeps its own lines.
110+- **Build before finishing**, and keep `build/` out of git.
new file mode 100644
@@ -0,0 +1,110 @@
1+---
2+name: ebook-builder
3+description: Create, generate and update an AsciiDoc e-book from a per-chapter project (one directory per chapter). Produces one chapter file per project chapter, a master document, diagrams, and builds to HTML/PDF. Use when asked to build/refresh an e-book or PDF book from a step-by-step project.
4+---
5+
6+# ebook-builder
7+
8+Turn a project organised as **one directory per chapter** (`01-…/`, `02-…/`, each with a `README.md`) into a coherent **AsciiDoc e-book** that builds to **HTML and PDF**. The book is **curated and synthesised** from the chapters — not a raw concatenation of READMEs: it reads as a narrative with a consistent voice.
9+
10+## When to use
11+
12+- "Make an e-book / a PDF book out of this project."
13+- "Update the book after I changed / added a chapter."
14+
15+## Dependencies
16+
17+Nothing local is required if Docker is available (the build script uses the official `asciidoctor/docker-asciidoctor` image). To build without Docker: `gem install asciidoctor asciidoctor-pdf rouge`.
18+
19+## Layout
20+
21+```text
22+ebook/
23+├── book.adoc ← master document: attributes + include::chapters/*
24+├── chapters/
25+│ ├── 00-introduction.adoc
26+│ ├── 01-….adoc ← one per project chapter (curated from its README)
27+│ └── 99-conclusion.adoc
28+├── images/
29+│ ├── *.drawio ← editable diagram sources
30+│ └── *.svg ← exported diagrams used by the book
31+├── Makefile ← optional convenience (pdf/html/docker-pdf)
32+├── README.adoc ← how to build/edit
33+└── build/ ← output (git-ignored)
34+```
35+
36+## The master `book.adoc`
37+
38+Standard AsciiDoc book attributes, then one `include::` per chapter in order:
39+
40+```asciidoc
41+= Book title
42+:doctype: book
43+:toc: left
44+:toclevels: 3
45+:sectnums:
46+:icons: font
47+:source-highlighter: rouge
48+:imagesdir: images
49+
50+[preface]
51+== Foreword
52+…
53+
54+include::chapters/00-introduction.adoc[]
55+include::chapters/01-….adoc[]
56+…
57+include::chapters/99-conclusion.adoc[]
58+```
59+
60+## Workflow
61+
62+### 1. Read the project
63+
64+List the chapter directories and read each `README.md`. Note, per chapter: the need it answers, the key config/code, the diagrams, the "what you retain".
65+
66+### 2. Scaffold (only if `ebook/` doesn't exist)
67+
68+Create the layout above: `book.adoc`, `chapters/`, `images/`, and (optionally) a `Makefile` + `README.adoc`. Add an `00-introduction` (hook + how the book is built) and a `99-conclusion`.
69+
70+### 3. Write one chapter file per project chapter
71+
72+Each `chapters/NN-….adoc` is **synthesised** from the matching README, in the book's voice. A reliable per-chapter shape:
73+
74+- `== <chapter title>`
75+- `=== À quoi on répond` — the need.
76+- `=== La configuration` / the key code, in a `[source,<lang>]` block (colourised via rouge).
77+- `=== Pourquoi` — the explanation.
78+- optional `=== Prompts d'essai` / a diagram (`image::name.svg[…]`).
79+- `=== Ce qu'on retient` — a short bullet recap.
80+
81+**Never hard-wrap prose in the `.adoc` files.** Write each paragraph, and each list item, as **one single physical line** — never insert a line break in the middle of a sentence to fit a column width. Let the editor soft-wrap. This keeps diffs meaningful (a reworded sentence touches one line) and avoids stray breaks. Leave code blocks (`----`), tables (`|===`), headings, block macros (`image::`), and admonition delimiters (`====`) as they are — the rule is about *prose*, not block structure. When editing a chapter that was hard-wrapped, unwrap the paragraphs you touch.
82+
83+### 4. Diagrams
84+
85+Author diagrams as `.drawio` (editable) + export `.svg` (used by the book), kept in `images/`. Hand-authored SVGs are fine too. After writing, sanity-check the XML parses.
86+
87+### 5. Build
88+
89+```bash
90+bash .claude/skills/ebook-builder/scripts/build_ebook.sh ebook # html + pdf
91+bash .claude/skills/ebook-builder/scripts/build_ebook.sh ebook html # html only
92+```
93+
94+Confirm it builds with no errors and the diagrams appear.
95+
96+### 6. Update
97+
98+- Chapter changed → update its `.adoc`.
99+- Chapter added → add `chapters/NN-….adoc` **and** an `include::` line in `book.adoc` (in order).
100+- Chapter removed → delete the file and its `include::`.
101+- Re-build to verify.
102+
103+## Rules
104+
105+- **Synthesise, don't concatenate.** The book is a narrative, not a README dump.
106+- **Keep chapter order** aligned with the numbered directories.
107+- **Colourise code** with `[source,<lang>]` blocks.
108+- **Diagrams as drawio + svg**, referenced from the chapters.
109+- **Don't hard-wrap prose (hard rule).** One single physical line per paragraph and per list item in the `.adoc` files — no mid-sentence line breaks. Only block structure (code, tables, headings, macros, `====`) keeps its own lines.
110+- **Build before finishing**, and keep `build/` out of git.
added .claude/skills/ebook-builder/scripts/build_ebook.sh +44 -0
new file mode 100755
@@ -0,0 +1,44 @@
1+#!/usr/bin/env bash
2+# Build an AsciiDoc ebook to HTML and PDF.
3+#
4+# Reusable: point it at an ebook directory that contains a `book.adoc` master.
5+# Uses the official Asciidoctor Docker image, so no local Ruby is required.
6+# If `asciidoctor` is on PATH, it is used directly instead.
7+#
8+# Usage:
9+# build_ebook.sh [EBOOK_DIR] # default: ./ebook
10+# build_ebook.sh ./ebook html # only html (targets: html | pdf | all[default])
11+
12+set -eu
13+
14+DIR="${1:-ebook}"
15+TARGET="${2:-all}"
16+SRC="book.adoc"
17+ATTRS=(-a source-highlighter=rouge)
18+
19+if [ ! -f "$DIR/$SRC" ]; then
20+ echo "No $DIR/$SRC found (need an AsciiDoc master document)." >&2
21+ exit 1
22+fi
23+
24+run() {
25+ # $1 = asciidoctor binary/subcommand, rest = args
26+ if command -v asciidoctor >/dev/null 2>&1; then
27+ (cd "$DIR" && "$@")
28+ else
29+ (cd "$DIR" && docker run --rm -v "$PWD":/documents asciidoctor/docker-asciidoctor "$@")
30+ fi
31+}
32+
33+case "$TARGET" in
34+html | all)
35+ run asciidoctor "${ATTRS[@]}" -D build -o book.html "$SRC"
36+ echo "→ $DIR/build/book.html"
37+ ;;
38+esac
39+case "$TARGET" in
40+pdf | all)
41+ run asciidoctor-pdf "${ATTRS[@]}" -D build -o book.pdf "$SRC"
42+ echo "→ $DIR/build/book.pdf"
43+ ;;
44+esac
new file mode 100755
@@ -0,0 +1,44 @@
1+#!/usr/bin/env bash
2+# Build an AsciiDoc ebook to HTML and PDF.
3+#
4+# Reusable: point it at an ebook directory that contains a `book.adoc` master.
5+# Uses the official Asciidoctor Docker image, so no local Ruby is required.
6+# If `asciidoctor` is on PATH, it is used directly instead.
7+#
8+# Usage:
9+# build_ebook.sh [EBOOK_DIR] # default: ./ebook
10+# build_ebook.sh ./ebook html # only html (targets: html | pdf | all[default])
11+
12+set -eu
13+
14+DIR="${1:-ebook}"
15+TARGET="${2:-all}"
16+SRC="book.adoc"
17+ATTRS=(-a source-highlighter=rouge)
18+
19+if [ ! -f "$DIR/$SRC" ]; then
20+ echo "No $DIR/$SRC found (need an AsciiDoc master document)." >&2
21+ exit 1
22+fi
23+
24+run() {
25+ # $1 = asciidoctor binary/subcommand, rest = args
26+ if command -v asciidoctor >/dev/null 2>&1; then
27+ (cd "$DIR" && "$@")
28+ else
29+ (cd "$DIR" && docker run --rm -v "$PWD":/documents asciidoctor/docker-asciidoctor "$@")
30+ fi
31+}
32+
33+case "$TARGET" in
34+html | all)
35+ run asciidoctor "${ATTRS[@]}" -D build -o book.html "$SRC"
36+ echo "→ $DIR/build/book.html"
37+ ;;
38+esac
39+case "$TARGET" in
40+pdf | all)
41+ run asciidoctor-pdf "${ATTRS[@]}" -D build -o book.pdf "$SRC"
42+ echo "→ $DIR/build/book.pdf"
43+ ;;
44+esac
added .claude/skills/methodical-dev/SKILL.md +603 -0
new file mode 100644
@@ -0,0 +1,603 @@
1+---
2+name: methodical-dev
3+description: Guide the user through a structured development methodology, following best practices for working with Claude Code in a controlled and effective way. Every change is delivered complete: readable and documented code, unit tests with a documented way to run them, bilingual (EN + FR) Diátaxis documentation, a passing quality gate, and an updated .memory/ project record. Use when starting a feature, refactoring, or whenever you want a controlled, approval-gated development process.
4+---
5+# Methodical Development Skill
6+
7+## Description
8+Guide the user through a structured development methodology, following best practices for
9+working with Claude Code in a controlled and effective way.
10+
11+The core promise: **no change is "done" until it is readable, tested, documented in both
12+English and French, quality-gated, and recorded in the project's memory.** The phases below
13+enforce that; none of them is optional.
14+
15+## When to Use
16+- When starting a new feature
17+- When refactoring or changing existing behaviour
18+- When you want to follow a structured process
19+- To avoid the common pitfalls of AI-assisted development
20+
21+## Companion skills
22+
23+This skill orchestrates two others. Both ship alongside it and must be **invoked as skills**,
24+not reimplemented:
25+
26+| Skill | When this skill invokes it | Phase |
27+| --- | --- | --- |
28+| `quality` | After implementation, to measure quality and refactor until the gate passes | Phase 5 |
29+| `diataxis-doc` | After the quality gate, to write or update the documentation | Phase 6 |
30+
31+## Instructions
32+
33+You are a skill that guides the user through a rigorous development methodology. You must
34+follow this process step by step.
35+
36+---
37+
38+## Phase 0: Read the project memory
39+
40+**Before asking the user anything**, read `.memory/` at the repository root:
41+
42+```bash
43+ls -la .memory/ 2>/dev/null && cat .memory/summary.md 2>/dev/null
44+```
45+
46+- If `.memory/` exists, read `summary.md`, the tail of `history.md`, and the most recent file
47+ in `handoffs/`. It tells you the project's current state, past decisions, and any work left
48+ in flight — **do not ask the user for information that is already recorded there.**
49+- If `.memory/` does not exist, you will create it in Phase 7. Do not create it yet.
50+
51+If a handoff records unfinished work, surface it now and ask whether to resume it or start
52+something new.
53+
54+---
55+
56+## Phase 1: Gathering Information
57+
58+Ask the user these questions using AskUserQuestion — skipping any the memory already answers:
59+
60+1. **Goal of the feature**
61+ - What feature do you want to develop?
62+ - What is the exact scope of this feature?
63+
64+2. **Technical Constraints**
65+ - Which frameworks/libraries must you use?
66+ - Are there any version constraints?
67+ - Are there architectural patterns to follow?
68+
69+3. **Documentation and Examples**
70+ - Do you have documentation to reference?
71+ - Do you have similar existing code that could serve as an example?
72+
73+4. **Style and Conventions**
74+ - Are there specific naming conventions?
75+ - Is there a particular code style to follow?
76+
77+---
78+
79+## Phase 2: Git Check
80+
81+Check the state of the repository:
82+
83+```bash
84+# Check that we are in a git repo
85+git status
86+
87+# If there is no repo, offer to initialise one
88+git init
89+```
90+
91+If the user is not on a dedicated branch, **strongly recommend** creating a feature branch.
92+
93+**IMPORTANT**: Do not create the branch automatically. Ask the user:
94+- What branch name would they like?
95+- Do they want you to create the branch, or would they rather do it themselves?
96+
97+---
98+
99+## Phase 3: Detailed Planning
100+
101+1. **Analyse the existing code** (if needed)
102+ - Use Glob and Grep to understand the structure
103+ - Identify the files to modify
104+ - Identify the existing patterns to follow
105+
106+2. **Identify the project's test and build commands.** Look for a `Makefile`, `Taskfile.yml`,
107+ `package.json` scripts, `pyproject.toml`, or the CI workflow. You need these for Phase 4
108+ and Phase 5, and you must reuse the project's existing convention rather than inventing a
109+ parallel one.
110+
111+3. **Create a detailed plan** using TodoWrite
112+ - Break the feature into logical steps (5–8 steps maximum)
113+ - Each step must be atomic and testable
114+ - Order the steps by dependency
115+ - **Every implementation step carries its own unit tests** — do not plan a single
116+ "write the tests" step at the end
117+ - Include the mandatory closing phases as plan items: quality gate, bilingual
118+ documentation, memory update
119+
120+4. **Present the plan** to the user
121+ - Explain each step
122+ - Ask for approval before continuing
123+ - Allow adjustments
124+
125+---
126+
127+## Phase 4: Guided Implementation
128+
129+For each step of the plan:
130+
131+1. **Before starting the step**
132+ - Mark the step as `in_progress` with TodoWrite
133+ - Explain what you are about to do
134+ - Ask for confirmation if the step is complex
135+
136+2. **During the step**
137+ - Implement only what is planned for this step
138+ - Write the code to the **Code Standards** below — readable, maintainable, documented
139+ with usage examples
140+ - Write or update the **unit tests** for this step's behaviour (see Testing Requirements)
141+ - Do **NOT** take shortcuts
142+ - Do **NOT** delete existing code without asking
143+ - Do **NOT** change the architecture without agreement
144+ - Explain the technical choices as you go
145+
146+3. **After the step**
147+ - Run the tests and show the result — a step whose tests do not pass is not finished
148+ - Mark the step as `completed` with TodoWrite
149+ - Summarise what was done
150+ - List the files created/modified
151+ - **STOP and wait for the user's approval**
152+
153+4. **Mandatory checkpoint**
154+ - Ask the user to:
155+ - Review the code produced
156+ - Test the behaviour
157+ - Confirm it matches their request
158+ - Offer to:
159+ - Continue to the next step
160+ - Change something in the current step
161+ - Adjust the remaining plan
162+
163+---
164+
165+## Phase 5: Quality Gate (mandatory)
166+
167+**Invoke the `quality` skill.** Do not measure quality by eye and do not skip this phase,
168+even for a one-line change.
169+
170+The skill configures qlty if needed, measures lint issues / code smells / complexity, writes
171+a report under `.quality/`, records the run in its history so progression is visible, and
172+tells you whether the gate passed.
173+
174+- **Gate passed** → report the numbers and move to Phase 6.
175+- **Gate failed** → refactor as the skill directs, re-run the tests from Phase 4, and
176+ re-measure. Repeat until the gate passes or the skill's own stopping conditions are hit
177+ (five iterations, or two runs with no improvement).
178+- **Still failing at the stopping condition** → do not silently continue. Report what
179+ improved, what remains, and why, then ask the user how to proceed.
180+
181+Never satisfy the gate by weakening it. Editing `.qlty/qlty.toml` exclusions, lowering
182+`.quality/gate.json` thresholds, or adding blanket lint suppressions is forbidden here for the
183+same reason it is forbidden inside the `quality` skill: it makes the measurement lie.
184+
185+For a long multi-step feature, running the quality skill after a large step — not only at the
186+end — catches drift earlier and is cheaper than one big cleanup.
187+
188+---
189+
190+## Phase 6: Documentation (mandatory, bilingual)
191+
192+**Invoke the `diataxis-doc` skill** to write or update the documentation for what you just
193+built. Every creation and every change gets documented — new behaviour, changed behaviour,
194+and removed behaviour alike.
195+
196+> **Do not ask the user which language to document in.** `diataxis-doc` normally opens by
197+> asking; when invoked from this skill the answer is already fixed: **Both** — English *and*
198+> French. Tell the skill this so it goes straight to the bilingual architecture.
199+
200+This produces one subfolder per language, each with its own complete four-quadrant structure:
201+
202+```
203+docs/
204+├── README.md ← language selector
205+├── en/
206+│ ├── README.md
207+│ ├── tutorials/ ← "teach me to get started"
208+│ ├── how-to/ ← "how do I do X?"
209+│ ├── reference/ ← "what are the exact details of X?"
210+│ └── explanation/ ← "why is it built this way?"
211+└── fr/
212+ └── … same structure
213+```
214+
215+Rules that matter here:
216+
217+- **Both languages stay in sync.** A change documented in English but not French is an
218+ incomplete change. Translate the content — do not copy the English text into `fr/`.
219+- **Cross-links stay inside one language.** A French page never links to an English page.
220+- **File the content with the Diátaxis compass**, do not dump everything into one page. New
221+ feature → usually a `how-to/` page plus a `reference/` entry; a design decision →
222+ `explanation/`.
223+- **The way to run the tests is documented**, in both languages (see Testing Requirements).
224+- **Every touched package's `README.md` is updated.** This is separate from `docs/` and applies to **all** packages, in whatever language that README already uses. Any package whose public surface you changed — a new, renamed, or removed exported function, type, method, flag, or CLI subcommand — gets its `README.md` brought back in sync **in the same change**. A README that lists part of a package's API but omits what you just added is a defect, not merely "a bit behind". If the repository keeps no per-package READMEs, this rule is inert — do not create them just to satisfy it.
225+- **A package-dependency diagram is kept in draw.io format.** It shows every package/module, the dependency arrows between them (and on third-party runtimes), and a one-line "what it is for" on each. Keep it at `docs/diagrams/packages.drawio` (or the project's existing diagrams location). **Create it when it does not exist, and update it whenever you add, remove, or re-wire a package** so it never drifts from the real import graph. Keep it as a single language-neutral file (labels in the docs' primary language), referenced from the architecture explanation page in each language. A `.drawio` file is plain XML (an `mxGraphModel`) that diagrams.net and the VS Code Draw.io extension open directly, so you can author and edit it as text; after writing it, sanity-check that the XML parses.
226+
227+---
228+
229+## Phase 7: Update the project memory (mandatory)
230+
231+Create or update `.memory/` at the repository root. This is the project's durable record: it
232+is what lets you — or a different agent, in a fresh sandbox — pick the work up later without
233+re-deriving everything.
234+
235+```
236+.memory/
237+├── README.md ← what this folder is and how it is maintained
238+├── summary.md ← living snapshot of the project's current state (edited in place)
239+├── history.md ← append-only chronological log (never rewritten)
240+└── handoffs/
241+ └── YYYY-MM-DD-<slug>.md ← one per session or feature
242+```
243+
244+`.memory/` is **committed to the repository**, not gitignored — that is the whole point.
245+
246+### `summary.md` — edited in place, never regenerated
247+
248+The current state of the project, kept short enough to stay read-worthy:
249+
250+- What the project is and does
251+- Architecture: main components and how they fit together
252+- Key technical decisions currently in force, and why
253+- How to build, test, and run it (the actual commands)
254+- Known limitations and open questions
255+
256+**Change only what this session establishes or invalidates, and leave the rest alone.** You have
257+seen a slice of the project; this file holds what every previous session established. Regenerating
258+it wholesale from your slice silently destroys accurate content you never looked at — the one way
259+this file stops being trustworthy. Write only what you verified; anything you could not check goes
260+under an explicit `## Not yet established` heading instead of a plausible guess, because the next
261+session will trust whatever is written here. If the project's state did not change, leave the file
262+untouched.
263+
264+### `history.md` — append only
265+
266+One dated entry per completed feature or session. **Never rewrite or delete past entries** —
267+a history you edit is not a history.
268+
269+```markdown
270+## 2026-07-25 — <what was done>
271+
272+- **Goal**: <what the user asked for>
273+- **Changes**: <files/components created or modified>
274+- **Decisions**: <choices made and why; alternatives rejected>
275+- **Tests**: <what was added, how to run it>
276+- **Quality**: <gate PASS/FAIL and the key numbers>
277+- **Docs**: <pages created/updated, in both languages>
278+```
279+
280+### `handoffs/YYYY-MM-DD-<slug>.md` — written at the end of the session
281+
282+The document a fresh agent reads to resume:
283+
284+```markdown
285+# Handoff — <date> — <topic>
286+
287+## State
288+<what works right now; what was just finished>
289+
290+## In flight
291+<work started but not finished, and exactly where it stopped>
292+
293+## Next steps
294+1. <the concrete next action>
295+2. …
296+
297+## Open questions / blockers
298+<decisions awaiting the user; anything that blocked progress>
299+
300+## Watch out for
301+<traps discovered along the way — failing commands, fragile assumptions>
302+```
303+
304+### Memory vs. documentation
305+
306+Keep the boundary clean, so content is not duplicated:
307+
308+| | Audience | Content |
309+| --- | --- | --- |
310+| `docs/` (Diátaxis) | **Users** of the project | How to use it, what the API is, why it is designed that way |
311+| `.memory/` | **Whoever continues the work** | Project history, session state, handoffs, decisions in progress |
312+
313+If a piece of information helps someone *use* the project, it belongs in `docs/`. If it helps
314+someone *continue building* it, it belongs in `.memory/`.
315+
316+---
317+
318+## Phase 8: Final Validation
319+
320+Once all the steps and phases are complete:
321+
322+1. **Full summary**
323+ - List of all files created
324+ - List of all files modified
325+ - Summary of the features implemented
326+
327+2. **Quality checklist**
328+ - [ ] Does the feature match the request exactly?
329+ - [ ] No unrequested deletions?
330+ - [ ] Is the code readable, maintainable, and documented with usage examples?
331+ - [ ] Are unit tests present for every change, and do they pass?
332+ - [ ] Is there a documented, single command to run the tests?
333+ - [ ] Did the `quality` skill run, and did the gate pass?
334+ - [ ] Is the documentation updated in **both** English and French?
335+ - [ ] Is every touched package's `README.md` updated to match its current public API?
336+ - [ ] Is the package-dependency draw.io diagram present and consistent with the current import graph?
337+ - [ ] Are the conventions respected?
338+ - [ ] Is `.memory/` updated — `summary.md`, a new `history.md` entry, and a handoff?
339+
340+3. **Commit proposal**
341+ - Propose a structured commit message
342+ - List the files to add to the commit — including `docs/` and `.memory/`
343+ - Do **NOT** commit automatically
344+ - Let the user do it, or use the /commit skill
345+
346+---
347+
348+## Code Standards
349+
350+Generated code must be readable and maintainable by a **human**, not merely correct. Someone
351+unfamiliar with it should understand it without asking you.
352+
353+**Readability**
354+- Names state intent: `retryAfterSeconds`, not `d` or `tmp2`. No abbreviations that are not
355+ domain-standard.
356+- One function does one thing, and is short enough to read without scrolling.
357+- Prefer the explicit over the clever. A dense one-liner that needs a comment to be understood
358+ should be several plain lines instead.
359+- Early returns over deep nesting.
360+- **Match the surrounding code.** Its existing style wins over your preferences.
361+
362+**Maintainability**
363+- No duplicated logic — extract it the second time it appears.
364+- Errors are handled where they can be handled meaningfully, never silently swallowed.
365+- No dead code, no commented-out code, no `TODO` without a concrete follow-up noted in
366+ `.memory/handoffs/`.
367+- Keep the public surface small: expose what callers need, no more.
368+
369+**Documentation in the code**
370+- Every public/exported function, type, class, and module gets a doc comment saying what it
371+ does, what it expects, and what it returns or raises.
372+- **Each public API's doc comment includes a short usage example** — a few lines a reader can
373+ copy. Use the language's idiom for this: Go `Example` functions (which are also tests),
374+ Python docstring examples, JSDoc `@example`, Rust doc-tests.
375+- Comments explain **why**, not what. If a comment restates the code, delete it and improve
376+ the name instead.
377+- Document non-obvious constraints and invariants at the point they apply.
378+
379+**Markdown and prose files**
380+- Applies to every Markdown file you write — `docs/`, `.memory/`, `README`s.
381+- **Do not hard-wrap prose.** Write each paragraph, list item, and blockquote as
382+ a single unwrapped line; never break a line in the middle of a sentence. Let the
383+ reader's editor soft-wrap. This keeps diffs meaningful — a reworded sentence
384+ touches one line, not a whole reflowed block.
385+- Leave fenced code blocks, tables, and headings as they are (code keeps its own
386+ newlines; one table row per line; a heading on its own line).
387+- When editing a file that was previously hard-wrapped, unwrap the blocks you touch.
388+
389+---
390+
391+## Testing Requirements
392+
393+Every addition, creation, and change ships with tests. A change without tests is not finished.
394+
395+**What to write**
396+- Unit tests for each new or modified behaviour, added in the same step as the code.
397+- Cover the happy path **and** every error or edge case the code explicitly handles.
398+- When fixing a bug, first write the test that reproduces it, and confirm it fails before the
399+ fix.
400+- Tests are deterministic: no real network, no wall-clock or random dependence, no reliance on
401+ test execution order.
402+- Test names state the behaviour under test, so a failure is legible without reading the body.
403+
404+**How to run them**
405+- There must be **one documented command** that runs the whole suite.
406+- **Reuse the project's existing convention** — add a `Makefile` / `Taskfile.yml` target, or a
407+ `package.json` script, if one of those is already in use. Only create a
408+ `scripts/test.sh`-style runner when the project has no such entry point, and make it
409+ executable (`chmod +x`) with a `set -eu` guard.
410+- The command must work from a clean checkout, with no undocumented manual setup.
411+
412+**Where to document it**
413+- In `docs/` under both languages — usually a `how-to/` page ("How to run the tests").
414+- In `.memory/summary.md`, in the build/test/run section.
415+- In the project `README` if it already documents commands.
416+
417+---
418+
419+## Strict Rules
420+
421+**You must NEVER:**
422+- ❌ Create a commit without an explicit request
423+- ❌ Delete existing code without confirmation
424+- ❌ Change the architecture without agreement
425+- ❌ Skip a step without approval
426+- ❌ Continue if the user has not approved the previous step
427+- ❌ Take shortcuts "to keep things simple"
428+- ❌ Implement something different from what was asked
429+- ❌ Declare a change done without tests, docs in both languages, an updated `README.md` for
430+ every touched package, a passing quality gate, and an updated `.memory/`
431+- ❌ Weaken the quality gate, delete tests, or disable a linter to make a check pass
432+- ❌ Document in only one language
433+- ❌ Rewrite or delete past `history.md` entries
434+
435+**You must ALWAYS:**
436+- ✅ Read `.memory/` before asking the user anything
437+- ✅ Stop after each step for approval
438+- ✅ Explain your technical choices
439+- ✅ Ask for confirmation on important decisions
440+- ✅ Follow the approved plan exactly
441+- ✅ Be transparent about what you are doing
442+- ✅ Propose alternatives if you see a problem
443+- ✅ Write tests alongside the code, in the same step
444+- ✅ Invoke the `quality` skill before declaring the work complete
445+- ✅ Invoke the `diataxis-doc` skill for documentation, in English and French
446+- ✅ Update `.memory/` at the end of the session
447+
448+---
449+
450+## Handling Problems
451+
452+If you hit a problem during implementation:
453+
454+1. **STOP immediately**
455+2. Explain the problem clearly
456+3. Propose alternative solutions
457+4. **Wait** for the user's decision
458+5. **NEVER** work around the problem by deleting code
459+
460+If the session ends with the problem unresolved, record it in
461+`.memory/handoffs/` under "Open questions / blockers" before stopping.
462+
463+---
464+
465+## Communication Format
466+
467+Use this format to communicate clearly:
468+
469+```
470+=== STEP [N]: [Step name] ===
471+
472+📋 What I am going to do:
473+- [Action 1]
474+- [Action 2]
475+
476+✅ Approval needed? [Yes/No]
477+
478+[If Yes, wait for a reply before continuing]
479+
480+---
481+
482+[Implementation + tests]
483+
484+---
485+
486+📊 STEP [N] SUMMARY:
487+✅ Created: [file1], [file2]
488+✅ Modified: [file3]
489+🧪 Tests: [what was added] — [pass/fail, command used]
490+✅ Feature: [description]
491+
492+⏸️ CHECKPOINT
493+Please review and approve before continuing.
494+
495+Options:
496+1. ✅ Continue to the next step
497+2. 🔧 Change something
498+3. 📝 Adjust the plan
499+```
500+
501+For the closing phases:
502+
503+```
504+=== PHASE 5: QUALITY GATE ===
505+🔍 Invoking the `quality` skill…
506+📊 Gate: [PASS/FAIL] — errors: [n], warnings: [n], smells: [n]
507+[If FAIL: refactoring, then re-measuring]
508+
509+=== PHASE 6: DOCUMENTATION (EN + FR) ===
510+📚 Invoking the `diataxis-doc` skill (language: Both)…
511+✅ docs/en/: [pages]
512+✅ docs/fr/: [pages]
513+
514+=== PHASE 7: PROJECT MEMORY ===
515+🧠 .memory/summary.md — updated
516+🧠 .memory/history.md — entry appended
517+🧠 .memory/handoffs/… — written
518+```
519+
520+---
521+
522+## Usage Example
523+
524+```
525+User: /methodical-dev
526+
527+Skill: I will guide you through a methodical development process.
528+
529+=== PHASE 0: PROJECT MEMORY ===
530+
531+[Reads .memory/summary.md, history.md, handoffs/]
532+
533+Found a handoff from 2026-07-20: the parser is done, the encoder was left
534+half-written. Resume that, or start something new?
535+
536+=== PHASE 1: GATHERING INFORMATION ===
537+
538+[Asks the remaining questions via AskUserQuestion]
539+
540+=== PHASE 2: GIT CHECK ===
541+
542+[Checks git status]
543+
544+=== PHASE 3: PLANNING ===
545+
546+Here is the proposed plan:
547+
548+□ Step 1: Create the base structure + tests
549+□ Step 2: Implement the business logic + tests
550+□ Step 3: Wire up the public API + doc comments with examples
551+□ Step 4: Quality gate (quality skill)
552+□ Step 5: Documentation EN + FR (diataxis-doc skill)
553+□ Step 6: Update .memory/
554+
555+Does this plan work for you?
556+
557+[Wait for approval]
558+
559+=== PHASE 4: IMPLEMENTATION ===
560+
561+=== STEP 1: Create the base structure ===
562+
563+📋 What I am going to do:
564+- Create src/feature/index.ts
565+- Create src/feature/types.ts
566+- Create src/feature/index.test.ts
567+- Set up the exports
568+
569+[Implementation + tests]
570+
571+📊 STEP 1 SUMMARY:
572+✅ Created: src/feature/index.ts, src/feature/types.ts
573+✅ Modified: src/index.ts (exports)
574+🧪 Tests: src/feature/index.test.ts — 4 passing (npm test)
575+
576+⏸️ CHECKPOINT - Approval?
577+
578+...
579+
580+=== PHASE 5: QUALITY GATE ===
581+📊 Gate: PASS — errors: 0, warnings: 0, smells: 0
582+
583+=== PHASE 6: DOCUMENTATION (EN + FR) ===
584+✅ docs/en/how-to/use-feature.md, docs/en/reference/feature.md
585+✅ docs/fr/how-to/utiliser-feature.md, docs/fr/reference/feature.md
586+
587+=== PHASE 7: PROJECT MEMORY ===
588+🧠 .memory/ updated (summary, history entry, handoff)
589+
590+=== PHASE 8: FINAL VALIDATION ===
591+[Checklist + commit proposal]
592+```
593+
594+## Notes
595+
596+This skill is designed to maximise the user's control while still benefiting from AI
597+assistance. It forces a stop at every step to avoid the common drift of AI assistants.
598+
599+The user always stays in charge and can step in at any time.
600+
601+The closing phases (quality, documentation, memory) are what stop a feature from being
602+"finished" in the narrow sense — code that runs — while leaving behind untested logic,
603+undocumented behaviour, and no trace of why any of it was done that way.
new file mode 100644
@@ -0,0 +1,603 @@
1+---
2+name: methodical-dev
3+description: Guide the user through a structured development methodology, following best practices for working with Claude Code in a controlled and effective way. Every change is delivered complete: readable and documented code, unit tests with a documented way to run them, bilingual (EN + FR) Diátaxis documentation, a passing quality gate, and an updated .memory/ project record. Use when starting a feature, refactoring, or whenever you want a controlled, approval-gated development process.
4+---
5+# Methodical Development Skill
6+
7+## Description
8+Guide the user through a structured development methodology, following best practices for
9+working with Claude Code in a controlled and effective way.
10+
11+The core promise: **no change is "done" until it is readable, tested, documented in both
12+English and French, quality-gated, and recorded in the project's memory.** The phases below
13+enforce that; none of them is optional.
14+
15+## When to Use
16+- When starting a new feature
17+- When refactoring or changing existing behaviour
18+- When you want to follow a structured process
19+- To avoid the common pitfalls of AI-assisted development
20+
21+## Companion skills
22+
23+This skill orchestrates two others. Both ship alongside it and must be **invoked as skills**,
24+not reimplemented:
25+
26+| Skill | When this skill invokes it | Phase |
27+| --- | --- | --- |
28+| `quality` | After implementation, to measure quality and refactor until the gate passes | Phase 5 |
29+| `diataxis-doc` | After the quality gate, to write or update the documentation | Phase 6 |
30+
31+## Instructions
32+
33+You are a skill that guides the user through a rigorous development methodology. You must
34+follow this process step by step.
35+
36+---
37+
38+## Phase 0: Read the project memory
39+
40+**Before asking the user anything**, read `.memory/` at the repository root:
41+
42+```bash
43+ls -la .memory/ 2>/dev/null && cat .memory/summary.md 2>/dev/null
44+```
45+
46+- If `.memory/` exists, read `summary.md`, the tail of `history.md`, and the most recent file
47+ in `handoffs/`. It tells you the project's current state, past decisions, and any work left
48+ in flight — **do not ask the user for information that is already recorded there.**
49+- If `.memory/` does not exist, you will create it in Phase 7. Do not create it yet.
50+
51+If a handoff records unfinished work, surface it now and ask whether to resume it or start
52+something new.
53+
54+---
55+
56+## Phase 1: Gathering Information
57+
58+Ask the user these questions using AskUserQuestion — skipping any the memory already answers:
59+
60+1. **Goal of the feature**
61+ - What feature do you want to develop?
62+ - What is the exact scope of this feature?
63+
64+2. **Technical Constraints**
65+ - Which frameworks/libraries must you use?
66+ - Are there any version constraints?
67+ - Are there architectural patterns to follow?
68+
69+3. **Documentation and Examples**
70+ - Do you have documentation to reference?
71+ - Do you have similar existing code that could serve as an example?
72+
73+4. **Style and Conventions**
74+ - Are there specific naming conventions?
75+ - Is there a particular code style to follow?
76+
77+---
78+
79+## Phase 2: Git Check
80+
81+Check the state of the repository:
82+
83+```bash
84+# Check that we are in a git repo
85+git status
86+
87+# If there is no repo, offer to initialise one
88+git init
89+```
90+
91+If the user is not on a dedicated branch, **strongly recommend** creating a feature branch.
92+
93+**IMPORTANT**: Do not create the branch automatically. Ask the user:
94+- What branch name would they like?
95+- Do they want you to create the branch, or would they rather do it themselves?
96+
97+---
98+
99+## Phase 3: Detailed Planning
100+
101+1. **Analyse the existing code** (if needed)
102+ - Use Glob and Grep to understand the structure
103+ - Identify the files to modify
104+ - Identify the existing patterns to follow
105+
106+2. **Identify the project's test and build commands.** Look for a `Makefile`, `Taskfile.yml`,
107+ `package.json` scripts, `pyproject.toml`, or the CI workflow. You need these for Phase 4
108+ and Phase 5, and you must reuse the project's existing convention rather than inventing a
109+ parallel one.
110+
111+3. **Create a detailed plan** using TodoWrite
112+ - Break the feature into logical steps (5–8 steps maximum)
113+ - Each step must be atomic and testable
114+ - Order the steps by dependency
115+ - **Every implementation step carries its own unit tests** — do not plan a single
116+ "write the tests" step at the end
117+ - Include the mandatory closing phases as plan items: quality gate, bilingual
118+ documentation, memory update
119+
120+4. **Present the plan** to the user
121+ - Explain each step
122+ - Ask for approval before continuing
123+ - Allow adjustments
124+
125+---
126+
127+## Phase 4: Guided Implementation
128+
129+For each step of the plan:
130+
131+1. **Before starting the step**
132+ - Mark the step as `in_progress` with TodoWrite
133+ - Explain what you are about to do
134+ - Ask for confirmation if the step is complex
135+
136+2. **During the step**
137+ - Implement only what is planned for this step
138+ - Write the code to the **Code Standards** below — readable, maintainable, documented
139+ with usage examples
140+ - Write or update the **unit tests** for this step's behaviour (see Testing Requirements)
141+ - Do **NOT** take shortcuts
142+ - Do **NOT** delete existing code without asking
143+ - Do **NOT** change the architecture without agreement
144+ - Explain the technical choices as you go
145+
146+3. **After the step**
147+ - Run the tests and show the result — a step whose tests do not pass is not finished
148+ - Mark the step as `completed` with TodoWrite
149+ - Summarise what was done
150+ - List the files created/modified
151+ - **STOP and wait for the user's approval**
152+
153+4. **Mandatory checkpoint**
154+ - Ask the user to:
155+ - Review the code produced
156+ - Test the behaviour
157+ - Confirm it matches their request
158+ - Offer to:
159+ - Continue to the next step
160+ - Change something in the current step
161+ - Adjust the remaining plan
162+
163+---
164+
165+## Phase 5: Quality Gate (mandatory)
166+
167+**Invoke the `quality` skill.** Do not measure quality by eye and do not skip this phase,
168+even for a one-line change.
169+
170+The skill configures qlty if needed, measures lint issues / code smells / complexity, writes
171+a report under `.quality/`, records the run in its history so progression is visible, and
172+tells you whether the gate passed.
173+
174+- **Gate passed** → report the numbers and move to Phase 6.
175+- **Gate failed** → refactor as the skill directs, re-run the tests from Phase 4, and
176+ re-measure. Repeat until the gate passes or the skill's own stopping conditions are hit
177+ (five iterations, or two runs with no improvement).
178+- **Still failing at the stopping condition** → do not silently continue. Report what
179+ improved, what remains, and why, then ask the user how to proceed.
180+
181+Never satisfy the gate by weakening it. Editing `.qlty/qlty.toml` exclusions, lowering
182+`.quality/gate.json` thresholds, or adding blanket lint suppressions is forbidden here for the
183+same reason it is forbidden inside the `quality` skill: it makes the measurement lie.
184+
185+For a long multi-step feature, running the quality skill after a large step — not only at the
186+end — catches drift earlier and is cheaper than one big cleanup.
187+
188+---
189+
190+## Phase 6: Documentation (mandatory, bilingual)
191+
192+**Invoke the `diataxis-doc` skill** to write or update the documentation for what you just
193+built. Every creation and every change gets documented — new behaviour, changed behaviour,
194+and removed behaviour alike.
195+
196+> **Do not ask the user which language to document in.** `diataxis-doc` normally opens by
197+> asking; when invoked from this skill the answer is already fixed: **Both** — English *and*
198+> French. Tell the skill this so it goes straight to the bilingual architecture.
199+
200+This produces one subfolder per language, each with its own complete four-quadrant structure:
201+
202+```
203+docs/
204+├── README.md ← language selector
205+├── en/
206+│ ├── README.md
207+│ ├── tutorials/ ← "teach me to get started"
208+│ ├── how-to/ ← "how do I do X?"
209+│ ├── reference/ ← "what are the exact details of X?"
210+│ └── explanation/ ← "why is it built this way?"
211+└── fr/
212+ └── … same structure
213+```
214+
215+Rules that matter here:
216+
217+- **Both languages stay in sync.** A change documented in English but not French is an
218+ incomplete change. Translate the content — do not copy the English text into `fr/`.
219+- **Cross-links stay inside one language.** A French page never links to an English page.
220+- **File the content with the Diátaxis compass**, do not dump everything into one page. New
221+ feature → usually a `how-to/` page plus a `reference/` entry; a design decision →
222+ `explanation/`.
223+- **The way to run the tests is documented**, in both languages (see Testing Requirements).
224+- **Every touched package's `README.md` is updated.** This is separate from `docs/` and applies to **all** packages, in whatever language that README already uses. Any package whose public surface you changed — a new, renamed, or removed exported function, type, method, flag, or CLI subcommand — gets its `README.md` brought back in sync **in the same change**. A README that lists part of a package's API but omits what you just added is a defect, not merely "a bit behind". If the repository keeps no per-package READMEs, this rule is inert — do not create them just to satisfy it.
225+- **A package-dependency diagram is kept in draw.io format.** It shows every package/module, the dependency arrows between them (and on third-party runtimes), and a one-line "what it is for" on each. Keep it at `docs/diagrams/packages.drawio` (or the project's existing diagrams location). **Create it when it does not exist, and update it whenever you add, remove, or re-wire a package** so it never drifts from the real import graph. Keep it as a single language-neutral file (labels in the docs' primary language), referenced from the architecture explanation page in each language. A `.drawio` file is plain XML (an `mxGraphModel`) that diagrams.net and the VS Code Draw.io extension open directly, so you can author and edit it as text; after writing it, sanity-check that the XML parses.
226+
227+---
228+
229+## Phase 7: Update the project memory (mandatory)
230+
231+Create or update `.memory/` at the repository root. This is the project's durable record: it
232+is what lets you — or a different agent, in a fresh sandbox — pick the work up later without
233+re-deriving everything.
234+
235+```
236+.memory/
237+├── README.md ← what this folder is and how it is maintained
238+├── summary.md ← living snapshot of the project's current state (edited in place)
239+├── history.md ← append-only chronological log (never rewritten)
240+└── handoffs/
241+ └── YYYY-MM-DD-<slug>.md ← one per session or feature
242+```
243+
244+`.memory/` is **committed to the repository**, not gitignored — that is the whole point.
245+
246+### `summary.md` — edited in place, never regenerated
247+
248+The current state of the project, kept short enough to stay read-worthy:
249+
250+- What the project is and does
251+- Architecture: main components and how they fit together
252+- Key technical decisions currently in force, and why
253+- How to build, test, and run it (the actual commands)
254+- Known limitations and open questions
255+
256+**Change only what this session establishes or invalidates, and leave the rest alone.** You have
257+seen a slice of the project; this file holds what every previous session established. Regenerating
258+it wholesale from your slice silently destroys accurate content you never looked at — the one way
259+this file stops being trustworthy. Write only what you verified; anything you could not check goes
260+under an explicit `## Not yet established` heading instead of a plausible guess, because the next
261+session will trust whatever is written here. If the project's state did not change, leave the file
262+untouched.
263+
264+### `history.md` — append only
265+
266+One dated entry per completed feature or session. **Never rewrite or delete past entries** —
267+a history you edit is not a history.
268+
269+```markdown
270+## 2026-07-25 — <what was done>
271+
272+- **Goal**: <what the user asked for>
273+- **Changes**: <files/components created or modified>
274+- **Decisions**: <choices made and why; alternatives rejected>
275+- **Tests**: <what was added, how to run it>
276+- **Quality**: <gate PASS/FAIL and the key numbers>
277+- **Docs**: <pages created/updated, in both languages>
278+```
279+
280+### `handoffs/YYYY-MM-DD-<slug>.md` — written at the end of the session
281+
282+The document a fresh agent reads to resume:
283+
284+```markdown
285+# Handoff — <date> — <topic>
286+
287+## State
288+<what works right now; what was just finished>
289+
290+## In flight
291+<work started but not finished, and exactly where it stopped>
292+
293+## Next steps
294+1. <the concrete next action>
295+2. …
296+
297+## Open questions / blockers
298+<decisions awaiting the user; anything that blocked progress>
299+
300+## Watch out for
301+<traps discovered along the way — failing commands, fragile assumptions>
302+```
303+
304+### Memory vs. documentation
305+
306+Keep the boundary clean, so content is not duplicated:
307+
308+| | Audience | Content |
309+| --- | --- | --- |
310+| `docs/` (Diátaxis) | **Users** of the project | How to use it, what the API is, why it is designed that way |
311+| `.memory/` | **Whoever continues the work** | Project history, session state, handoffs, decisions in progress |
312+
313+If a piece of information helps someone *use* the project, it belongs in `docs/`. If it helps
314+someone *continue building* it, it belongs in `.memory/`.
315+
316+---
317+
318+## Phase 8: Final Validation
319+
320+Once all the steps and phases are complete:
321+
322+1. **Full summary**
323+ - List of all files created
324+ - List of all files modified
325+ - Summary of the features implemented
326+
327+2. **Quality checklist**
328+ - [ ] Does the feature match the request exactly?
329+ - [ ] No unrequested deletions?
330+ - [ ] Is the code readable, maintainable, and documented with usage examples?
331+ - [ ] Are unit tests present for every change, and do they pass?
332+ - [ ] Is there a documented, single command to run the tests?
333+ - [ ] Did the `quality` skill run, and did the gate pass?
334+ - [ ] Is the documentation updated in **both** English and French?
335+ - [ ] Is every touched package's `README.md` updated to match its current public API?
336+ - [ ] Is the package-dependency draw.io diagram present and consistent with the current import graph?
337+ - [ ] Are the conventions respected?
338+ - [ ] Is `.memory/` updated — `summary.md`, a new `history.md` entry, and a handoff?
339+
340+3. **Commit proposal**
341+ - Propose a structured commit message
342+ - List the files to add to the commit — including `docs/` and `.memory/`
343+ - Do **NOT** commit automatically
344+ - Let the user do it, or use the /commit skill
345+
346+---
347+
348+## Code Standards
349+
350+Generated code must be readable and maintainable by a **human**, not merely correct. Someone
351+unfamiliar with it should understand it without asking you.
352+
353+**Readability**
354+- Names state intent: `retryAfterSeconds`, not `d` or `tmp2`. No abbreviations that are not
355+ domain-standard.
356+- One function does one thing, and is short enough to read without scrolling.
357+- Prefer the explicit over the clever. A dense one-liner that needs a comment to be understood
358+ should be several plain lines instead.
359+- Early returns over deep nesting.
360+- **Match the surrounding code.** Its existing style wins over your preferences.
361+
362+**Maintainability**
363+- No duplicated logic — extract it the second time it appears.
364+- Errors are handled where they can be handled meaningfully, never silently swallowed.
365+- No dead code, no commented-out code, no `TODO` without a concrete follow-up noted in
366+ `.memory/handoffs/`.
367+- Keep the public surface small: expose what callers need, no more.
368+
369+**Documentation in the code**
370+- Every public/exported function, type, class, and module gets a doc comment saying what it
371+ does, what it expects, and what it returns or raises.
372+- **Each public API's doc comment includes a short usage example** — a few lines a reader can
373+ copy. Use the language's idiom for this: Go `Example` functions (which are also tests),
374+ Python docstring examples, JSDoc `@example`, Rust doc-tests.
375+- Comments explain **why**, not what. If a comment restates the code, delete it and improve
376+ the name instead.
377+- Document non-obvious constraints and invariants at the point they apply.
378+
379+**Markdown and prose files**
380+- Applies to every Markdown file you write — `docs/`, `.memory/`, `README`s.
381+- **Do not hard-wrap prose.** Write each paragraph, list item, and blockquote as
382+ a single unwrapped line; never break a line in the middle of a sentence. Let the
383+ reader's editor soft-wrap. This keeps diffs meaningful — a reworded sentence
384+ touches one line, not a whole reflowed block.
385+- Leave fenced code blocks, tables, and headings as they are (code keeps its own
386+ newlines; one table row per line; a heading on its own line).
387+- When editing a file that was previously hard-wrapped, unwrap the blocks you touch.
388+
389+---
390+
391+## Testing Requirements
392+
393+Every addition, creation, and change ships with tests. A change without tests is not finished.
394+
395+**What to write**
396+- Unit tests for each new or modified behaviour, added in the same step as the code.
397+- Cover the happy path **and** every error or edge case the code explicitly handles.
398+- When fixing a bug, first write the test that reproduces it, and confirm it fails before the
399+ fix.
400+- Tests are deterministic: no real network, no wall-clock or random dependence, no reliance on
401+ test execution order.
402+- Test names state the behaviour under test, so a failure is legible without reading the body.
403+
404+**How to run them**
405+- There must be **one documented command** that runs the whole suite.
406+- **Reuse the project's existing convention** — add a `Makefile` / `Taskfile.yml` target, or a
407+ `package.json` script, if one of those is already in use. Only create a
408+ `scripts/test.sh`-style runner when the project has no such entry point, and make it
409+ executable (`chmod +x`) with a `set -eu` guard.
410+- The command must work from a clean checkout, with no undocumented manual setup.
411+
412+**Where to document it**
413+- In `docs/` under both languages — usually a `how-to/` page ("How to run the tests").
414+- In `.memory/summary.md`, in the build/test/run section.
415+- In the project `README` if it already documents commands.
416+
417+---
418+
419+## Strict Rules
420+
421+**You must NEVER:**
422+- ❌ Create a commit without an explicit request
423+- ❌ Delete existing code without confirmation
424+- ❌ Change the architecture without agreement
425+- ❌ Skip a step without approval
426+- ❌ Continue if the user has not approved the previous step
427+- ❌ Take shortcuts "to keep things simple"
428+- ❌ Implement something different from what was asked
429+- ❌ Declare a change done without tests, docs in both languages, an updated `README.md` for
430+ every touched package, a passing quality gate, and an updated `.memory/`
431+- ❌ Weaken the quality gate, delete tests, or disable a linter to make a check pass
432+- ❌ Document in only one language
433+- ❌ Rewrite or delete past `history.md` entries
434+
435+**You must ALWAYS:**
436+- ✅ Read `.memory/` before asking the user anything
437+- ✅ Stop after each step for approval
438+- ✅ Explain your technical choices
439+- ✅ Ask for confirmation on important decisions
440+- ✅ Follow the approved plan exactly
441+- ✅ Be transparent about what you are doing
442+- ✅ Propose alternatives if you see a problem
443+- ✅ Write tests alongside the code, in the same step
444+- ✅ Invoke the `quality` skill before declaring the work complete
445+- ✅ Invoke the `diataxis-doc` skill for documentation, in English and French
446+- ✅ Update `.memory/` at the end of the session
447+
448+---
449+
450+## Handling Problems
451+
452+If you hit a problem during implementation:
453+
454+1. **STOP immediately**
455+2. Explain the problem clearly
456+3. Propose alternative solutions
457+4. **Wait** for the user's decision
458+5. **NEVER** work around the problem by deleting code
459+
460+If the session ends with the problem unresolved, record it in
461+`.memory/handoffs/` under "Open questions / blockers" before stopping.
462+
463+---
464+
465+## Communication Format
466+
467+Use this format to communicate clearly:
468+
469+```
470+=== STEP [N]: [Step name] ===
471+
472+📋 What I am going to do:
473+- [Action 1]
474+- [Action 2]
475+
476+✅ Approval needed? [Yes/No]
477+
478+[If Yes, wait for a reply before continuing]
479+
480+---
481+
482+[Implementation + tests]
483+
484+---
485+
486+📊 STEP [N] SUMMARY:
487+✅ Created: [file1], [file2]
488+✅ Modified: [file3]
489+🧪 Tests: [what was added] — [pass/fail, command used]
490+✅ Feature: [description]
491+
492+⏸️ CHECKPOINT
493+Please review and approve before continuing.
494+
495+Options:
496+1. ✅ Continue to the next step
497+2. 🔧 Change something
498+3. 📝 Adjust the plan
499+```
500+
501+For the closing phases:
502+
503+```
504+=== PHASE 5: QUALITY GATE ===
505+🔍 Invoking the `quality` skill…
506+📊 Gate: [PASS/FAIL] — errors: [n], warnings: [n], smells: [n]
507+[If FAIL: refactoring, then re-measuring]
508+
509+=== PHASE 6: DOCUMENTATION (EN + FR) ===
510+📚 Invoking the `diataxis-doc` skill (language: Both)…
511+✅ docs/en/: [pages]
512+✅ docs/fr/: [pages]
513+
514+=== PHASE 7: PROJECT MEMORY ===
515+🧠 .memory/summary.md — updated
516+🧠 .memory/history.md — entry appended
517+🧠 .memory/handoffs/… — written
518+```
519+
520+---
521+
522+## Usage Example
523+
524+```
525+User: /methodical-dev
526+
527+Skill: I will guide you through a methodical development process.
528+
529+=== PHASE 0: PROJECT MEMORY ===
530+
531+[Reads .memory/summary.md, history.md, handoffs/]
532+
533+Found a handoff from 2026-07-20: the parser is done, the encoder was left
534+half-written. Resume that, or start something new?
535+
536+=== PHASE 1: GATHERING INFORMATION ===
537+
538+[Asks the remaining questions via AskUserQuestion]
539+
540+=== PHASE 2: GIT CHECK ===
541+
542+[Checks git status]
543+
544+=== PHASE 3: PLANNING ===
545+
546+Here is the proposed plan:
547+
548+□ Step 1: Create the base structure + tests
549+□ Step 2: Implement the business logic + tests
550+□ Step 3: Wire up the public API + doc comments with examples
551+□ Step 4: Quality gate (quality skill)
552+□ Step 5: Documentation EN + FR (diataxis-doc skill)
553+□ Step 6: Update .memory/
554+
555+Does this plan work for you?
556+
557+[Wait for approval]
558+
559+=== PHASE 4: IMPLEMENTATION ===
560+
561+=== STEP 1: Create the base structure ===
562+
563+📋 What I am going to do:
564+- Create src/feature/index.ts
565+- Create src/feature/types.ts
566+- Create src/feature/index.test.ts
567+- Set up the exports
568+
569+[Implementation + tests]
570+
571+📊 STEP 1 SUMMARY:
572+✅ Created: src/feature/index.ts, src/feature/types.ts
573+✅ Modified: src/index.ts (exports)
574+🧪 Tests: src/feature/index.test.ts — 4 passing (npm test)
575+
576+⏸️ CHECKPOINT - Approval?
577+
578+...
579+
580+=== PHASE 5: QUALITY GATE ===
581+📊 Gate: PASS — errors: 0, warnings: 0, smells: 0
582+
583+=== PHASE 6: DOCUMENTATION (EN + FR) ===
584+✅ docs/en/how-to/use-feature.md, docs/en/reference/feature.md
585+✅ docs/fr/how-to/utiliser-feature.md, docs/fr/reference/feature.md
586+
587+=== PHASE 7: PROJECT MEMORY ===
588+🧠 .memory/ updated (summary, history entry, handoff)
589+
590+=== PHASE 8: FINAL VALIDATION ===
591+[Checklist + commit proposal]
592+```
593+
594+## Notes
595+
596+This skill is designed to maximise the user's control while still benefiting from AI
597+assistance. It forces a stop at every step to avoid the common drift of AI assistants.
598+
599+The user always stays in charge and can step in at any time.
600+
601+The closing phases (quality, documentation, memory) are what stop a feature from being
602+"finished" in the narrow sense — code that runs — while leaving behind untested logic,
603+undocumented behaviour, and no trace of why any of it was done that way.
added .claude/skills/quality/SKILL.md +266 -0
new file mode 100644
@@ -0,0 +1,266 @@
1+---
2+name: quality
3+description: Audit and improve this project's code quality with qlty. Sets qlty up on first use, measures lint issues / code smells / complexity, writes a timestamped Markdown report under .quality/, tracks progression across runs, and refactors until the quality gate passes. Use when asked to check code quality, run a quality report, review technical debt, see whether quality is improving, or clean up / refactor the codebase.
4+---
5+
6+# Quality
7+
8+Measure this project's code quality with [qlty](https://docs.qlty.sh), report it, and
9+refactor until the gate passes.
10+
11+The measurement is a script, not a judgement call — run it, then act on what it says.
12+It is read-only with respect to the source tree (`qlty check` runs with `--no-fix`), so
13+measuring never quietly rewrites the code you are about to reason about.
14+
15+## 1. Set up (only if needed)
16+
17+The setup is self-healing; just run the script. It verifies `qlty` is on `PATH`, that
18+the workspace is a git repository, and runs `qlty init --yes` when there is no
19+`.qlty/qlty.toml` yet.
20+
21+If `qlty` is missing entirely, the sandbox was not created with the `dev-toolkit`
22+kit. Say so and stop — do not install qlty by hand.
23+
24+**If the project vendors this kit** — a `kits/` directory holding the sources that get
25+installed into `~/.claude/` — add it to `exclude_patterns` in `.qlty/qlty.toml` on the
26+first run:
27+
28+```toml
29+exclude_patterns = [
30+ # … the defaults qlty init wrote …
31+ "kits/**", # the kit's own sources, including this skill's quality_report.py
32+]
33+```
34+
35+This is **not** an exception to "do not game the gate" below. The reason is narrower and
36+holds regardless of what the numbers say: `quality_report.py` is the script that
37+*produces* the measurement, so leaving it in scope means measuring the instrument with
38+itself. Two of its findings are structurally unfixable — a script whose job is to run
39+`qlty` must import and call `subprocess`, which every Python security linter flags.
40+
41+Two conditions make this legitimate rather than convenient, and both must hold:
42+
43+- **It is the project's decision, not yours.** Say what the exclusion hides and why, and
44+ let the user choose. Adding it unilaterally is the forbidden move.
45+- **Write down what it hides.** Put the actual findings in a comment next to the pattern.
46+ Some of them are usually real defects in the script — over-complex functions, unused
47+ imports — and they should be fixed at the kit's source rather than forgotten. An
48+ exclusion that records what it silences stays honest; one that just makes a number
49+ drop is the start of the drift.
50+
51+## 2. Measure
52+
53+From the project root:
54+
55+```bash
56+python3 ~/.claude/skills/quality/scripts/quality_report.py --workspace .
57+```
58+
59+On a cold cache the first run takes several minutes: qlty downloads its plugin
60+definitions and the linter binaries. Subsequent runs are fast (the cache lives on a
61+persistent volume at `~/.qlty`).
62+
63+The script:
64+
65+- runs `qlty check --all`, `qlty smells --all`, and `qlty metrics --all`
66+- writes `.quality/report-<timestamp>.md` and `.quality/report-latest.md`
67+- appends one JSON line to `.quality/history.jsonl`
68+- prints the report to stdout
69+- **exits `0` if the gate passed, `1` if it failed, `2` on a tooling error**
70+
71+Use the exit code as the loop condition. Do not re-derive the verdict yourself.
72+
73+## 3. Read the report
74+
75+The report contains:
76+
77+| Section | What to do with it |
78+| --- | --- |
79+| **Gate violations** | The work list. Each line is a threshold that was breached. |
80+| **Lint issues** by level, top rules, most affected files | Where to start — but read the count correctly first, see below. |
81+| **Code smells** | Duplication and over-complex structure — these need real refactoring, not a formatter. |
82+| **Metrics** | `complex` / `cyclo` per file; the "Most complex files" table names refactoring targets. |
83+| **Trend** | Last 10 runs. This is how you show progression. |
84+| **Tooling notes** | Present only when a qlty invocation failed — investigate before trusting the numbers. |
85+
86+### Read the count before you trust it
87+
88+"Fix the highest-count rule first" is the right instinct and the wrong first step. A
89+count is `occurrences`, not `distinct problems`, and the two diverge badly in two common
90+cases:
91+
92+- **One finding, repeated per manifest.** `osv-scanner` reports a dependency CVE once per
93+ `go.mod` / `package.json` it appears in. A monorepo with a dozen manifests turns one
94+ vulnerable version into a hundred-plus findings, and adding a three-line example
95+ manifest multiplies the total without degrading anything.
96+- **One finding, repeated per file.** A linter that fails to parse the language emits one
97+ finding per source file. That looks like the largest cluster in the report and is worth
98+ exactly nothing.
99+
100+And before treating a *delta* as a regression: **re-run.** A count that moves while the
101+tree is untouched is measuring the tool, not the code. The usual cause is a linter
102+answering from its own partially-populated cache — qlty invokes several with
103+`--allow-parallel-runners`, and an invocation returning in a fraction of a second did
104+not re-analyse anything. That failure mode **under-reports**: the low number is the
105+wrong one, so it looks like an improvement.
106+
107+When you suspect it, clear that linter's cache and run its binary directly, several
108+times, until the result is stable; `.qlty/out/invoke-*.yaml` records the exact command,
109+environment and duration qlty used. Work from that figure, not from the report, and say
110+in your report which number you trust and why. Remember too that **a gate threshold
111+pinned to an unstable measurement will trip on its own** — give it margin, and write
112+down that the margin is the tool's fault, so it gets removed rather than inherited.
113+
114+So before picking a target, check **how many distinct rules and how many distinct files**
115+a count spans:
116+
117+```bash
118+# qlty check has no --json; SARIF is the machine-readable form
119+qlty check --all --no-fix --no-upgrade-check --sarif > /tmp/qlty.sarif
120+```
121+
122+Then group the results by `ruleId` and by file URI. A rule spanning many files with one
123+occurrence each, or many rules spanning the same short file list, means the count is
124+telling you about the *shape of the scan*, not the state of the code. Say so in your
125+report — a total nobody can interpret gets ignored, and then a real regression hides in
126+it.
127+
128+## 4. Refactor until the gate passes
129+
130+If the gate failed, refactor. Loop:
131+
132+1. Pick the largest cluster of violations from the report (one rule, or one file).
133+2. Fix formatting mechanically first — `qlty fmt` handles it, and it clears a lot of
134+ noise cheaply:
135+ ```bash
136+ qlty fmt --all --no-upgrade-check
137+ ```
138+3. Fix the remaining issues by editing the code. For smells, prefer extracting a
139+ function or collapsing a duplicated block over suppressing the finding.
140+4. Verify nothing broke, using **this project's own** build and test commands. The
141+ repository's tooling is the authority on what those are — check for a `Makefile`,
142+ `Taskfile.yml`, `package.json` scripts, `pyproject.toml`, or the CI workflow before
143+ assuming. Common cases:
144+
145+ | Stack | Verification |
146+ | --- | --- |
147+ | Go | `go build ./... && go test ./...` |
148+ | Go targeting WebAssembly | also `tinygo build -target=wasip1 ./...` — TinyGo rejects some constructs the standard toolchain accepts, so a passing `go build` is not sufficient |
149+ | Node / TypeScript | `npm test` (or the script the project defines), plus `tsc --noEmit` if the project is typed |
150+ | Python | `pytest` |
151+ | Rust | `cargo build && cargo test && cargo clippy -- -D warnings` |
152+ | Rust targeting WebAssembly | also `cargo build --target wasm32-wasip2` — and note the asymmetry below |
153+ | VS Code extension | the compile script, then `vsce package` — packaging validates the manifest, which `tsc` does not |
154+ | Zed extension | `cargo build --release --target wasm32-wasip2` |
155+
156+ If the project has no tests, say so in the report rather than letting a green build
157+ stand in as proof the refactoring was safe.
158+
159+ **Two traps specific to WebAssembly targets.** Both let a refactoring look verified
160+ when it is not:
161+
162+ - A **wasm build passing does not mean the tests ran.** `cargo build --target
163+ wasm32-wasip2` links with rustc's own bundled `rust-lld`, so it succeeds even
164+ with no C toolchain present, while `cargo test` — which builds for the *host* —
165+ fails at link time with ``error: linker `cc` not found``. If you see that error,
166+ the tests did not run; do not report the wasm build as verification.
167+ - An **extension cannot be exercised here.** No editor runs in this sandbox, so
168+ neither VS Code's Extension Development Host nor Zed's *Install Dev Extension*
169+ is available, and `@vscode/test-electron` needs an X server this kit does not
170+ install. Compile, unit-test and package — then say plainly that the integration
171+ path is untested rather than implying it passed.
172+5. Re-run the measurement from step 2 and compare against the previous run.
173+
174+Stop when any of these is true:
175+
176+- **The gate passes.** Report the before/after numbers from the Trend table.
177+- **Five iterations have run.** Stop and report honestly: what improved, what remains,
178+ and why the rest is hard.
179+- **Two consecutive runs show no improvement.** You are stuck — stop and explain what
180+ the blocker is rather than churning the code.
181+
182+Commit as you go, one focused commit per cluster of fixes, so the progression is
183+visible in git as well as in `.quality/history.jsonl`.
184+
185+## Do not game the gate
186+
187+The gate is only worth something if it is measuring the code. All of the following make
188+the number go down while making the project worse — never do them, even if the gate is
189+the only thing standing between you and "done":
190+
191+- Adding `exclude_patterns`, disabling a plugin, setting a plugin to `mode = "monitor"`,
192+ or raising a threshold in `.qlty/qlty.toml`.
193+- Loosening `.quality/gate.json`.
194+- Deleting, skipping, or emptying tests.
195+- Adding blanket lint suppressions (`//nolint`, `// qlty-ignore`) to silence a finding
196+ you could fix.
197+
198+A narrowly-scoped suppression with a comment explaining why the finding is a false
199+positive is legitimate. If you believe a threshold is genuinely wrong for this project,
200+say so and let the user decide — do not change it yourself.
201+
202+### The one plugin you may remove: a broken one
203+
204+"Disabling a plugin" above means a plugin that *finds things you would rather not fix*. A
205+plugin that **analyses nothing** is a different object, and keeping it is not rigour: its
206+findings crowd out the ones that matter and make the total unreadable.
207+
208+The two are indistinguishable from the report alone, so removal requires a **reproducible
209+diagnosis**, never an impression. Establish all four of these before touching the config:
210+
211+1. **It is not the language version or a specific construct.** Put a trivially small,
212+ unambiguously valid file of that language in a *fresh* git repository, enable only
213+ that plugin, and run it. If a five-line hello-world fails, no source construct is to
214+ blame.
215+2. **It is not this project.** Same point — the probe repository shares nothing with the
216+ codebase under measurement.
217+3. **It is not the environment qlty gives plugins.** qlty runs plugins with a stripped
218+ environment (empty `PATH` and `HOME`). Run the plugin's own binary directly, with your
219+ full environment, on the same probe file. Read the invocation qlty used from
220+ `.qlty/out/invoke-*.yaml`, which records the exact `script`, `env` and `stderr`.
221+4. **It is not the plugin family.** If sibling plugins sharing the same runtime produce
222+ real findings, the failure is specific rather than environmental.
223+
224+If all four hold, remove the plugin — and **write the diagnosis as a comment where you
225+removed it**, so the next session finds the evidence instead of re-adding it on instinct.
226+Note in your report which other tooling still covers that ground; if nothing does, the
227+answer is to replace the plugin, not merely to drop it.
228+
229+`mode = "monitor"` is *not* the remedy here. It does not stop a plugin's findings from
230+counting; it only changes how they are surfaced. A broken plugin left in monitor mode
231+keeps polluting the total, which is how one ends up unnoticed for months.
232+
233+**Do not encode "plugin X is broken" into this skill.** Such a failure belongs to one
234+version, one platform and one moment; a list of known-bad plugins ages badly and, worse,
235+tempts the next agent to skip the diagnosis. The method above is what belongs here — its
236+results belong in the project's own config and notes.
237+
238+## Tuning the gate
239+
240+Defaults are strict: zero error-level issues, zero warning-level issues, zero smells.
241+Override any subset by writing `.quality/gate.json`:
242+
243+```json
244+{
245+ "max_error": 0,
246+ "max_warning": 0,
247+ "max_note": null,
248+ "max_smells": 0,
249+ "max_file_complexity": 60,
250+ "max_total_complexity": null
251+}
252+```
253+
254+`null` disables a check. `max_file_complexity` flags individual files whose `complex`
255+metric exceeds the limit, which is the most useful knob for a codebase with existing
256+debt: it lets you hold the line on the worst files without demanding a perfect score
257+everywhere at once.
258+
259+## Reference
260+
261+- `qlty check --help`, `qlty smells --help`, `qlty metrics --help` for the full flag set.
262+- Note `qlty check` has **no** `--json`; machine-readable output is `--sarif`.
263+- Pass `--no-upgrade-check` to every qlty command — the sandbox blocks its update host.
264+- Reports and history under `.quality/` are meant to be committed so progression
265+ survives sandbox recreation. Add `.quality/` to `.gitignore` if the user prefers them
266+ to stay local.
new file mode 100644
@@ -0,0 +1,266 @@
1+---
2+name: quality
3+description: Audit and improve this project's code quality with qlty. Sets qlty up on first use, measures lint issues / code smells / complexity, writes a timestamped Markdown report under .quality/, tracks progression across runs, and refactors until the quality gate passes. Use when asked to check code quality, run a quality report, review technical debt, see whether quality is improving, or clean up / refactor the codebase.
4+---
5+
6+# Quality
7+
8+Measure this project's code quality with [qlty](https://docs.qlty.sh), report it, and
9+refactor until the gate passes.
10+
11+The measurement is a script, not a judgement call — run it, then act on what it says.
12+It is read-only with respect to the source tree (`qlty check` runs with `--no-fix`), so
13+measuring never quietly rewrites the code you are about to reason about.
14+
15+## 1. Set up (only if needed)
16+
17+The setup is self-healing; just run the script. It verifies `qlty` is on `PATH`, that
18+the workspace is a git repository, and runs `qlty init --yes` when there is no
19+`.qlty/qlty.toml` yet.
20+
21+If `qlty` is missing entirely, the sandbox was not created with the `dev-toolkit`
22+kit. Say so and stop — do not install qlty by hand.
23+
24+**If the project vendors this kit** — a `kits/` directory holding the sources that get
25+installed into `~/.claude/` — add it to `exclude_patterns` in `.qlty/qlty.toml` on the
26+first run:
27+
28+```toml
29+exclude_patterns = [
30+ # … the defaults qlty init wrote …
31+ "kits/**", # the kit's own sources, including this skill's quality_report.py
32+]
33+```
34+
35+This is **not** an exception to "do not game the gate" below. The reason is narrower and
36+holds regardless of what the numbers say: `quality_report.py` is the script that
37+*produces* the measurement, so leaving it in scope means measuring the instrument with
38+itself. Two of its findings are structurally unfixable — a script whose job is to run
39+`qlty` must import and call `subprocess`, which every Python security linter flags.
40+
41+Two conditions make this legitimate rather than convenient, and both must hold:
42+
43+- **It is the project's decision, not yours.** Say what the exclusion hides and why, and
44+ let the user choose. Adding it unilaterally is the forbidden move.
45+- **Write down what it hides.** Put the actual findings in a comment next to the pattern.
46+ Some of them are usually real defects in the script — over-complex functions, unused
47+ imports — and they should be fixed at the kit's source rather than forgotten. An
48+ exclusion that records what it silences stays honest; one that just makes a number
49+ drop is the start of the drift.
50+
51+## 2. Measure
52+
53+From the project root:
54+
55+```bash
56+python3 ~/.claude/skills/quality/scripts/quality_report.py --workspace .
57+```
58+
59+On a cold cache the first run takes several minutes: qlty downloads its plugin
60+definitions and the linter binaries. Subsequent runs are fast (the cache lives on a
61+persistent volume at `~/.qlty`).
62+
63+The script:
64+
65+- runs `qlty check --all`, `qlty smells --all`, and `qlty metrics --all`
66+- writes `.quality/report-<timestamp>.md` and `.quality/report-latest.md`
67+- appends one JSON line to `.quality/history.jsonl`
68+- prints the report to stdout
69+- **exits `0` if the gate passed, `1` if it failed, `2` on a tooling error**
70+
71+Use the exit code as the loop condition. Do not re-derive the verdict yourself.
72+
73+## 3. Read the report
74+
75+The report contains:
76+
77+| Section | What to do with it |
78+| --- | --- |
79+| **Gate violations** | The work list. Each line is a threshold that was breached. |
80+| **Lint issues** by level, top rules, most affected files | Where to start — but read the count correctly first, see below. |
81+| **Code smells** | Duplication and over-complex structure — these need real refactoring, not a formatter. |
82+| **Metrics** | `complex` / `cyclo` per file; the "Most complex files" table names refactoring targets. |
83+| **Trend** | Last 10 runs. This is how you show progression. |
84+| **Tooling notes** | Present only when a qlty invocation failed — investigate before trusting the numbers. |
85+
86+### Read the count before you trust it
87+
88+"Fix the highest-count rule first" is the right instinct and the wrong first step. A
89+count is `occurrences`, not `distinct problems`, and the two diverge badly in two common
90+cases:
91+
92+- **One finding, repeated per manifest.** `osv-scanner` reports a dependency CVE once per
93+ `go.mod` / `package.json` it appears in. A monorepo with a dozen manifests turns one
94+ vulnerable version into a hundred-plus findings, and adding a three-line example
95+ manifest multiplies the total without degrading anything.
96+- **One finding, repeated per file.** A linter that fails to parse the language emits one
97+ finding per source file. That looks like the largest cluster in the report and is worth
98+ exactly nothing.
99+
100+And before treating a *delta* as a regression: **re-run.** A count that moves while the
101+tree is untouched is measuring the tool, not the code. The usual cause is a linter
102+answering from its own partially-populated cache — qlty invokes several with
103+`--allow-parallel-runners`, and an invocation returning in a fraction of a second did
104+not re-analyse anything. That failure mode **under-reports**: the low number is the
105+wrong one, so it looks like an improvement.
106+
107+When you suspect it, clear that linter's cache and run its binary directly, several
108+times, until the result is stable; `.qlty/out/invoke-*.yaml` records the exact command,
109+environment and duration qlty used. Work from that figure, not from the report, and say
110+in your report which number you trust and why. Remember too that **a gate threshold
111+pinned to an unstable measurement will trip on its own** — give it margin, and write
112+down that the margin is the tool's fault, so it gets removed rather than inherited.
113+
114+So before picking a target, check **how many distinct rules and how many distinct files**
115+a count spans:
116+
117+```bash
118+# qlty check has no --json; SARIF is the machine-readable form
119+qlty check --all --no-fix --no-upgrade-check --sarif > /tmp/qlty.sarif
120+```
121+
122+Then group the results by `ruleId` and by file URI. A rule spanning many files with one
123+occurrence each, or many rules spanning the same short file list, means the count is
124+telling you about the *shape of the scan*, not the state of the code. Say so in your
125+report — a total nobody can interpret gets ignored, and then a real regression hides in
126+it.
127+
128+## 4. Refactor until the gate passes
129+
130+If the gate failed, refactor. Loop:
131+
132+1. Pick the largest cluster of violations from the report (one rule, or one file).
133+2. Fix formatting mechanically first — `qlty fmt` handles it, and it clears a lot of
134+ noise cheaply:
135+ ```bash
136+ qlty fmt --all --no-upgrade-check
137+ ```
138+3. Fix the remaining issues by editing the code. For smells, prefer extracting a
139+ function or collapsing a duplicated block over suppressing the finding.
140+4. Verify nothing broke, using **this project's own** build and test commands. The
141+ repository's tooling is the authority on what those are — check for a `Makefile`,
142+ `Taskfile.yml`, `package.json` scripts, `pyproject.toml`, or the CI workflow before
143+ assuming. Common cases:
144+
145+ | Stack | Verification |
146+ | --- | --- |
147+ | Go | `go build ./... && go test ./...` |
148+ | Go targeting WebAssembly | also `tinygo build -target=wasip1 ./...` — TinyGo rejects some constructs the standard toolchain accepts, so a passing `go build` is not sufficient |
149+ | Node / TypeScript | `npm test` (or the script the project defines), plus `tsc --noEmit` if the project is typed |
150+ | Python | `pytest` |
151+ | Rust | `cargo build && cargo test && cargo clippy -- -D warnings` |
152+ | Rust targeting WebAssembly | also `cargo build --target wasm32-wasip2` — and note the asymmetry below |
153+ | VS Code extension | the compile script, then `vsce package` — packaging validates the manifest, which `tsc` does not |
154+ | Zed extension | `cargo build --release --target wasm32-wasip2` |
155+
156+ If the project has no tests, say so in the report rather than letting a green build
157+ stand in as proof the refactoring was safe.
158+
159+ **Two traps specific to WebAssembly targets.** Both let a refactoring look verified
160+ when it is not:
161+
162+ - A **wasm build passing does not mean the tests ran.** `cargo build --target
163+ wasm32-wasip2` links with rustc's own bundled `rust-lld`, so it succeeds even
164+ with no C toolchain present, while `cargo test` — which builds for the *host* —
165+ fails at link time with ``error: linker `cc` not found``. If you see that error,
166+ the tests did not run; do not report the wasm build as verification.
167+ - An **extension cannot be exercised here.** No editor runs in this sandbox, so
168+ neither VS Code's Extension Development Host nor Zed's *Install Dev Extension*
169+ is available, and `@vscode/test-electron` needs an X server this kit does not
170+ install. Compile, unit-test and package — then say plainly that the integration
171+ path is untested rather than implying it passed.
172+5. Re-run the measurement from step 2 and compare against the previous run.
173+
174+Stop when any of these is true:
175+
176+- **The gate passes.** Report the before/after numbers from the Trend table.
177+- **Five iterations have run.** Stop and report honestly: what improved, what remains,
178+ and why the rest is hard.
179+- **Two consecutive runs show no improvement.** You are stuck — stop and explain what
180+ the blocker is rather than churning the code.
181+
182+Commit as you go, one focused commit per cluster of fixes, so the progression is
183+visible in git as well as in `.quality/history.jsonl`.
184+
185+## Do not game the gate
186+
187+The gate is only worth something if it is measuring the code. All of the following make
188+the number go down while making the project worse — never do them, even if the gate is
189+the only thing standing between you and "done":
190+
191+- Adding `exclude_patterns`, disabling a plugin, setting a plugin to `mode = "monitor"`,
192+ or raising a threshold in `.qlty/qlty.toml`.
193+- Loosening `.quality/gate.json`.
194+- Deleting, skipping, or emptying tests.
195+- Adding blanket lint suppressions (`//nolint`, `// qlty-ignore`) to silence a finding
196+ you could fix.
197+
198+A narrowly-scoped suppression with a comment explaining why the finding is a false
199+positive is legitimate. If you believe a threshold is genuinely wrong for this project,
200+say so and let the user decide — do not change it yourself.
201+
202+### The one plugin you may remove: a broken one
203+
204+"Disabling a plugin" above means a plugin that *finds things you would rather not fix*. A
205+plugin that **analyses nothing** is a different object, and keeping it is not rigour: its
206+findings crowd out the ones that matter and make the total unreadable.
207+
208+The two are indistinguishable from the report alone, so removal requires a **reproducible
209+diagnosis**, never an impression. Establish all four of these before touching the config:
210+
211+1. **It is not the language version or a specific construct.** Put a trivially small,
212+ unambiguously valid file of that language in a *fresh* git repository, enable only
213+ that plugin, and run it. If a five-line hello-world fails, no source construct is to
214+ blame.
215+2. **It is not this project.** Same point — the probe repository shares nothing with the
216+ codebase under measurement.
217+3. **It is not the environment qlty gives plugins.** qlty runs plugins with a stripped
218+ environment (empty `PATH` and `HOME`). Run the plugin's own binary directly, with your
219+ full environment, on the same probe file. Read the invocation qlty used from
220+ `.qlty/out/invoke-*.yaml`, which records the exact `script`, `env` and `stderr`.
221+4. **It is not the plugin family.** If sibling plugins sharing the same runtime produce
222+ real findings, the failure is specific rather than environmental.
223+
224+If all four hold, remove the plugin — and **write the diagnosis as a comment where you
225+removed it**, so the next session finds the evidence instead of re-adding it on instinct.
226+Note in your report which other tooling still covers that ground; if nothing does, the
227+answer is to replace the plugin, not merely to drop it.
228+
229+`mode = "monitor"` is *not* the remedy here. It does not stop a plugin's findings from
230+counting; it only changes how they are surfaced. A broken plugin left in monitor mode
231+keeps polluting the total, which is how one ends up unnoticed for months.
232+
233+**Do not encode "plugin X is broken" into this skill.** Such a failure belongs to one
234+version, one platform and one moment; a list of known-bad plugins ages badly and, worse,
235+tempts the next agent to skip the diagnosis. The method above is what belongs here — its
236+results belong in the project's own config and notes.
237+
238+## Tuning the gate
239+
240+Defaults are strict: zero error-level issues, zero warning-level issues, zero smells.
241+Override any subset by writing `.quality/gate.json`:
242+
243+```json
244+{
245+ "max_error": 0,
246+ "max_warning": 0,
247+ "max_note": null,
248+ "max_smells": 0,
249+ "max_file_complexity": 60,
250+ "max_total_complexity": null
251+}
252+```
253+
254+`null` disables a check. `max_file_complexity` flags individual files whose `complex`
255+metric exceeds the limit, which is the most useful knob for a codebase with existing
256+debt: it lets you hold the line on the worst files without demanding a perfect score
257+everywhere at once.
258+
259+## Reference
260+
261+- `qlty check --help`, `qlty smells --help`, `qlty metrics --help` for the full flag set.
262+- Note `qlty check` has **no** `--json`; machine-readable output is `--sarif`.
263+- Pass `--no-upgrade-check` to every qlty command — the sandbox blocks its update host.
264+- Reports and history under `.quality/` are meant to be committed so progression
265+ survives sandbox recreation. Add `.quality/` to `.gitignore` if the user prefers them
266+ to stay local.
added .claude/skills/quality/scripts/quality_report.py +463 -0
new file mode 100644
@@ -0,0 +1,463 @@
1+#!/usr/bin/env python3
2+"""Measure project code quality with qlty and emit a report, a history line, and a gate verdict.
3+
4+Read-only with respect to the source tree: `qlty check` runs with --no-fix so a
5+measurement never silently rewrites the code it is measuring.
6+
7+Exit codes: 0 gate passed, 1 gate failed, 2 tooling/setup error.
8+"""
9+
10+from __future__ import annotations
11+
12+import argparse
13+import json
14+import re
15+import shutil
16+
17+# nosec B404 - running qlty *is* this script's job. Every command it launches is a
18+# literal argv list built in this file; none is assembled from user input, and none
19+# goes through a shell. See the matching justification on subprocess.run below.
20+import subprocess # nosec B404
21+import sys
22+from datetime import datetime, timezone
23+from pathlib import Path
24+
25+ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
26+
27+# Overridable per project by writing .quality/gate.json with any subset of these
28+# keys. Kept strict on purpose: the gate is what the refactoring loop drives to
29+# zero. A null value disables that check.
30+DEFAULT_GATE = {
31+ "max_error": 0,
32+ "max_warning": 0,
33+ "max_note": None,
34+ "max_smells": 0,
35+ "max_file_complexity": None,
36+ "max_total_complexity": None,
37+}
38+
39+QLTY_COMMON = ["--no-upgrade-check"]
40+
41+
42+def run(cmd: list[str], cwd: Path, timeout: int = 1800) -> tuple[int, str, str]:
43+ try:
44+ # nosec B603 - cmd is always a literal argv list from this file (qlty or git
45+ # with fixed subcommands), never a string and never shell-interpreted, so
46+ # there is no injection surface. shell=False is the default and is what makes
47+ # this the safe form rather than the risky one.
48+ p = subprocess.run( # nosec B603
49+ cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout
50+ )
51+ except FileNotFoundError:
52+ return 127, "", f"command not found: {cmd[0]}"
53+ except subprocess.TimeoutExpired:
54+ return 124, "", f"timed out after {timeout}s: {' '.join(cmd)}"
55+ return p.returncode, p.stdout, p.stderr
56+
57+
58+def git(args: list[str], cwd: Path) -> str:
59+ code, out, _ = run(["git"] + args, cwd, timeout=60)
60+ return out.strip() if code == 0 else ""
61+
62+
63+def ensure_setup(ws: Path, allow_init: bool) -> None:
64+ """Verify qlty is usable here, running `qlty init` when the project has no config."""
65+ if shutil.which("qlty") is None:
66+ die(
67+ "qlty is not installed. This project expects the `dev-toolkit` sbx kit "
68+ "(see kits/dev-toolkit/README.md)."
69+ )
70+ if not (ws / ".git").exists() and not git(["rev-parse", "--git-dir"], ws):
71+ die(f"{ws} is not a git repository — qlty needs git to scope its analysis.")
72+
73+ if (ws / ".qlty" / "qlty.toml").is_file():
74+ return
75+ if not allow_init:
76+ die("no .qlty/qlty.toml found and --no-init was passed; run `qlty init --yes`.")
77+
78+ log("no .qlty/qlty.toml found — running `qlty init --yes` (first-time setup)")
79+ code, out, err = run(["qlty", "init", "--yes"] + QLTY_COMMON, ws, timeout=1800)
80+ if code != 0 or not (ws / ".qlty" / "qlty.toml").is_file():
81+ die(f"`qlty init` failed (exit {code}).\n{(out + err).strip()[-2000:]}")
82+ log("qlty initialised: .qlty/qlty.toml written")
83+
84+
85+def sarif_location(res: dict) -> tuple[str, int | None]:
86+ """Return the file and start line of a SARIF result, or ("", None) if it has none.
87+
88+ Every level of the structure is optional in the spec and plugins do omit them,
89+ hence the `or {}` at each step rather than a single chained access.
90+ """
91+ locs = res.get("locations") or []
92+ if not locs:
93+ return "", None
94+ phys = (locs[0] or {}).get("physicalLocation") or {}
95+ uri = ((phys.get("artifactLocation") or {}).get("uri")) or ""
96+ return uri, (phys.get("region") or {}).get("startLine")
97+
98+
99+def sarif_finding(res: dict) -> dict:
100+ """Flatten one SARIF result into the shape the rest of this script uses."""
101+ uri, line = sarif_location(res)
102+ return {
103+ "rule": res.get("ruleId") or "unknown",
104+ # SARIF omits `level` when it equals the rule default; treat an absent
105+ # level as "warning" rather than dropping the finding.
106+ "level": (res.get("level") or "warning").lower(),
107+ "message": ((res.get("message") or {}).get("text") or "").strip(),
108+ "file": uri,
109+ "line": line,
110+ }
111+
112+
113+def sarif_results(payload: str) -> list[dict]:
114+ """Flatten SARIF runs into a list of result dicts, tolerating partial output."""
115+ try:
116+ doc = json.loads(payload)
117+ except json.JSONDecodeError:
118+ return []
119+ return [
120+ sarif_finding(res)
121+ for run_ in doc.get("runs") or []
122+ for res in run_.get("results") or []
123+ ]
124+
125+
126+def collect_check(ws: Path) -> tuple[list[dict], str]:
127+ # --no-fail / --no-error: always emit a report instead of exiting on findings.
128+ # --no-fix: measurement must not mutate the tree.
129+ cmd = [
130+ "qlty", "check", "--all", "--no-fix", "--no-fail", "--no-error",
131+ "--no-progress", "--sarif",
132+ ] + QLTY_COMMON
133+ code, out, err = run(cmd, ws)
134+ results = sarif_results(out)
135+ note = "" if (results or code == 0) else f"`qlty check` exit {code}: {err.strip()[-500:]}"
136+ return results, note
137+
138+
139+def collect_smells(ws: Path) -> tuple[list[dict], str]:
140+ cmd = ["qlty", "smells", "--all", "--quiet", "--sarif"] + QLTY_COMMON
141+ code, out, err = run(cmd, ws)
142+ results = sarif_results(out)
143+ note = "" if (results or code == 0) else f"`qlty smells` exit {code}: {err.strip()[-500:]}"
144+ return results, note
145+
146+
147+def table_cells(text: str) -> list[list[str]]:
148+ """Split a pipe table into rows of cells, dropping rules and blank lines.
149+
150+ qlty renders an ANSI-coloured table and ignores NO_COLOR, so the escape codes
151+ are stripped here rather than by the caller.
152+ """
153+ rows: list[list[str]] = []
154+ for raw in text.splitlines():
155+ line = ANSI.sub("", raw).strip()
156+ if "|" not in line or set(line) <= set("-+| "):
157+ continue
158+ rows.append([c.strip() for c in line.split("|")])
159+ return rows
160+
161+
162+def metric_row(header: list[str], cells: list[str]) -> dict:
163+ """Pair a data row with its header, keeping `name` textual and the rest numeric."""
164+ row: dict = {}
165+ for key, val in zip(header, cells):
166+ if key == "name":
167+ row["name"] = val
168+ continue
169+ try:
170+ row[key] = int(val)
171+ except ValueError:
172+ row[key] = val
173+ return row
174+
175+
176+def parse_metrics_table(text: str) -> tuple[dict, list[dict]]:
177+ """Split qlty's metrics table into its TOTAL row and its per-file rows.
178+
179+ A row whose width does not match the header is skipped rather than guessed at:
180+ qlty occasionally wraps a long path, and inventing a value would silently
181+ corrupt the numbers this whole report rests on.
182+ """
183+ header: list[str] = []
184+ total: dict = {}
185+ rows: list[dict] = []
186+ for cells in table_cells(text):
187+ if not header:
188+ header = [c.lower() for c in cells]
189+ continue
190+ if len(cells) != len(header):
191+ continue
192+ row = metric_row(header, cells)
193+ if row.get("name", "").upper() == "TOTAL":
194+ total = {k: v for k, v in row.items() if k != "name"}
195+ else:
196+ rows.append(row)
197+ return total, rows
198+
199+
200+def collect_metrics(ws: Path) -> tuple[dict, list[dict], str]:
201+ """Run `qlty metrics` and return its TOTAL row, its per-file rows, and any note."""
202+ cmd = ["qlty", "metrics", "--all", "--quiet"] + QLTY_COMMON
203+ code, out, err = run(cmd, ws)
204+ if code != 0 and not out.strip():
205+ return {}, [], f"`qlty metrics` exit {code}: {err.strip()[-500:]}"
206+
207+ total, rows = parse_metrics_table(out)
208+ return total, rows, ""
209+
210+
211+def load_gate(ws: Path, override: Path | None) -> dict:
212+ gate = dict(DEFAULT_GATE)
213+ path = override or (ws / ".quality" / "gate.json")
214+ if path.is_file():
215+ try:
216+ gate.update(json.loads(path.read_text()))
217+ except (json.JSONDecodeError, OSError) as exc:
218+ die(f"could not read gate file {path}: {exc}")
219+ return gate
220+
221+
222+def evaluate(gate: dict, counts: dict, smells: int, metrics: dict, files: list[dict]) -> list[str]:
223+ """Return one human-readable violation string per breached threshold."""
224+ breaches: list[str] = []
225+
226+ def check(limit_key: str, actual: int, label: str) -> None:
227+ limit = gate.get(limit_key)
228+ if limit is not None and actual > limit:
229+ breaches.append(f"{label}: {actual} (max {limit})")
230+
231+ check("max_error", counts.get("error", 0), "error-level issues")
232+ check("max_warning", counts.get("warning", 0), "warning-level issues")
233+ check("max_note", counts.get("note", 0), "note-level issues")
234+ check("max_smells", smells, "code smells")
235+ check("max_total_complexity", metrics.get("complex", 0), "total complexity")
236+
237+ limit = gate.get("max_file_complexity")
238+ if limit is not None:
239+ over = [f for f in files if isinstance(f.get("complex"), int) and f["complex"] > limit]
240+ for f in sorted(over, key=lambda r: -r["complex"])[:10]:
241+ breaches.append(f"{f['name']} complexity {f['complex']} (max {limit})")
242+ return breaches
243+
244+
245+def tally(results: list[dict], key: str) -> dict[str, int]:
246+ counts: dict[str, int] = {}
247+ for r in results:
248+ counts[r[key]] = counts.get(r[key], 0) + 1
249+ return counts
250+
251+
252+def log(msg: str) -> None:
253+ print(f"[quality] {msg}", file=sys.stderr)
254+
255+
256+def die(msg: str) -> None:
257+ print(f"[quality] error: {msg}", file=sys.stderr)
258+ sys.exit(2)
259+
260+
261+def md_table(headers: list[str], rows: list[list[str]]) -> str:
262+ if not rows:
263+ return "_none_\n"
264+
265+ out = ["| " + " | ".join(headers) + " |", "|" + "|".join(["---"] * len(headers)) + "|"]
266+ out += ["| " + " | ".join(rows_) + " |" for rows_ in (map(str, r) for r in rows)]
267+ return "\n".join(out) + "\n"
268+
269+
270+def delta(cur: int, prev: int | None) -> str:
271+ if prev is None:
272+ return "—"
273+ d = cur - prev
274+ if d == 0:
275+ return "±0"
276+ return f"{d:+d}"
277+
278+
279+def report_header(entry: dict, prev: dict | None, breaches: list[str]) -> list[str]:
280+ verdict = "✅ **PASS**" if entry["gate_passed"] else "❌ **FAIL**"
281+ lines = [
282+ f"# Quality report — {entry['timestamp']}",
283+ "",
284+ f"- **Gate**: {verdict}",
285+ f"- **Commit**: `{entry['commit'] or 'n/a'}` on `{entry['branch'] or 'n/a'}`",
286+ f"- **qlty**: {entry['qlty_version']}",
287+ f"- **Run**: #{entry['run']}"
288+ + (f" (previous: {prev['timestamp']})" if prev else " (first recorded run)"),
289+ "",
290+ ]
291+ if breaches:
292+ lines += ["## Gate violations", ""]
293+ lines += [f"- {b}" for b in breaches]
294+ lines.append("")
295+ return lines
296+
297+
298+def report_issues(entry: dict, prev: dict | None, check_res: list[dict]) -> list[str]:
299+ """The lint section: counts by level, then the two "where to start" tables."""
300+ c = entry["counts"]
301+ p_counts = (prev or {}).get("counts") or {}
302+
303+ lines = ["## Lint issues (`qlty check`)", ""]
304+ lines.append(
305+ md_table(
306+ ["level", "count", "vs previous"],
307+ [[lvl, c.get(lvl, 0), delta(c.get(lvl, 0), p_counts.get(lvl))]
308+ for lvl in ("error", "warning", "note", "none")
309+ if c.get(lvl) or p_counts.get(lvl)],
310+ )
311+ )
312+
313+ by_rule = sorted(tally(check_res, "rule").items(), key=lambda kv: -kv[1])[:15]
314+ lines += ["### Top rules", "", md_table(["rule", "count"], [[k, v] for k, v in by_rule])]
315+
316+ by_file = sorted(tally([r for r in check_res if r["file"]], "file").items(),
317+ key=lambda kv: -kv[1])[:15]
318+ lines += ["### Most affected files", "",
319+ md_table(["file", "issues"], [[k, v] for k, v in by_file])]
320+ return lines
321+
322+
323+def report_smells(entry: dict, prev: dict | None, smell_res: list[dict]) -> list[str]:
324+ lines = ["## Code smells (`qlty smells`)", "",
325+ f"Total: **{entry['smells']}** (vs previous: "
326+ f"{delta(entry['smells'], (prev or {}).get('smells'))})", ""]
327+ smell_rows = [[r["rule"], r["file"] or "—", r["line"] or "—", r["message"][:110]]
328+ for r in smell_res[:20]]
329+ lines.append(md_table(["smell", "file", "line", "detail"], smell_rows))
330+ return lines
331+
332+
333+def report_metrics(entry: dict, prev: dict | None, files: list[dict]) -> list[str]:
334+ m = entry["metrics"]
335+ p_metrics = (prev or {}).get("metrics") or {}
336+
337+ lines = ["## Metrics (`qlty metrics`)", ""]
338+ metric_rows = [
339+ [k, m.get(k, "—"), delta(m[k], p_metrics.get(k)) if isinstance(m.get(k), int) else "—"]
340+ for k in ("funcs", "classes", "fields", "cyclo", "complex", "lcom", "lines", "loc")
341+ if k in m
342+ ]
343+ lines.append(md_table(["metric", "total", "vs previous"], metric_rows))
344+
345+ worst = sorted([f for f in files if isinstance(f.get("complex"), int)],
346+ key=lambda r: -r["complex"])[:15]
347+ lines += ["### Most complex files", "",
348+ md_table(["file", "complex", "cyclo", "loc"],
349+ [[f["name"], f.get("complex", "—"), f.get("cyclo", "—"),
350+ f.get("loc", "—")] for f in worst])]
351+ return lines
352+
353+
354+def report_trend(entry: dict) -> list[str]:
355+ return ["## Trend", "", md_table(
356+ ["run", "timestamp", "error", "warning", "smells", "complex", "gate"],
357+ [[h["run"], h["timestamp"], (h.get("counts") or {}).get("error", 0),
358+ (h.get("counts") or {}).get("warning", 0), h.get("smells", 0),
359+ (h.get("metrics") or {}).get("complex", "—"),
360+ "PASS" if h.get("gate_passed") else "FAIL"]
361+ for h in entry["_trend"]],
362+ )]
363+
364+
365+def build_report(entry: dict, prev: dict | None, check_res: list[dict],
366+ smell_res: list[dict], files: list[dict], breaches: list[str],
367+ notes: list[str]) -> str:
368+ """Assemble the Markdown report, in the order a reader works through it."""
369+ lines = report_header(entry, prev, breaches)
370+ lines += report_issues(entry, prev, check_res)
371+ lines += report_smells(entry, prev, smell_res)
372+ lines += report_metrics(entry, prev, files)
373+
374+ if notes:
375+ lines += ["## Tooling notes", ""] + [f"- {n}" for n in notes] + [""]
376+
377+ lines += report_trend(entry)
378+ return "\n".join(lines)
379+
380+
381+def main() -> int:
382+ ap = argparse.ArgumentParser(description=__doc__)
383+ ap.add_argument("--workspace", default=".", help="project root (default: cwd)")
384+ ap.add_argument("--no-init", action="store_true",
385+ help="fail instead of running `qlty init` when unconfigured")
386+ ap.add_argument("--gate", type=Path, help="gate JSON file (default: .quality/gate.json)")
387+ ap.add_argument("--json", action="store_true", help="print the history entry to stdout")
388+ args = ap.parse_args()
389+
390+ ws = Path(args.workspace).resolve()
391+ if not ws.is_dir():
392+ die(f"workspace {ws} does not exist")
393+
394+ ensure_setup(ws, allow_init=not args.no_init)
395+
396+ notes: list[str] = []
397+ log("running qlty check / smells / metrics (this can take several minutes on a cold cache)")
398+ check_res, n1 = collect_check(ws)
399+ smell_res, n2 = collect_smells(ws)
400+ metrics, files, n3 = collect_metrics(ws)
401+ notes += [n for n in (n1, n2, n3) if n]
402+
403+ counts = tally(check_res, "level")
404+ gate = load_gate(ws, args.gate)
405+ breaches = evaluate(gate, counts, len(smell_res), metrics, files)
406+
407+ _, ver_out, _ = run(["qlty", "--version"], ws, timeout=60)
408+ outdir = ws / ".quality"
409+ outdir.mkdir(parents=True, exist_ok=True)
410+ history = outdir / "history.jsonl"
411+
412+ past: list[dict] = []
413+ if history.is_file():
414+ for line in history.read_text().splitlines():
415+ line = line.strip()
416+ if line:
417+ try:
418+ past.append(json.loads(line))
419+ except json.JSONDecodeError:
420+ continue
421+
422+ now = datetime.now(timezone.utc)
423+ entry = {
424+ "run": len(past) + 1,
425+ "timestamp": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
426+ "commit": git(["rev-parse", "--short", "HEAD"], ws),
427+ "branch": git(["rev-parse", "--abbrev-ref", "HEAD"], ws),
428+ "qlty_version": ANSI.sub("", ver_out).strip() or "unknown",
429+ "counts": counts,
430+ "smells": len(smell_res),
431+ "metrics": metrics,
432+ "gate": {k: v for k, v in gate.items() if v is not None},
433+ "gate_passed": not breaches,
434+ "breaches": breaches,
435+ }
436+
437+ with history.open("a") as fh:
438+ fh.write(json.dumps(entry, sort_keys=True) + "\n")
439+
440+ entry["_trend"] = (past + [entry])[-10:]
441+ report = build_report(entry, past[-1] if past else None,
442+ check_res, smell_res, files, breaches, notes)
443+ del entry["_trend"]
444+
445+ stamp = now.strftime("%Y%m%dT%H%M%SZ")
446+ (outdir / f"report-{stamp}.md").write_text(report)
447+ (outdir / "report-latest.md").write_text(report)
448+
449+ print(report)
450+ log(f"report written to .quality/report-{stamp}.md (and report-latest.md)")
451+ log(f"history appended to .quality/history.jsonl (run #{entry['run']})")
452+ if args.json:
453+ print(json.dumps(entry, indent=2, sort_keys=True))
454+
455+ if breaches:
456+ log(f"GATE FAILED — {len(breaches)} violation(s)")
457+ return 1
458+ log("GATE PASSED")
459+ return 0
460+
461+
462+if __name__ == "__main__":
463+ sys.exit(main())
new file mode 100644
@@ -0,0 +1,463 @@
1+#!/usr/bin/env python3
2+"""Measure project code quality with qlty and emit a report, a history line, and a gate verdict.
3+
4+Read-only with respect to the source tree: `qlty check` runs with --no-fix so a
5+measurement never silently rewrites the code it is measuring.
6+
7+Exit codes: 0 gate passed, 1 gate failed, 2 tooling/setup error.
8+"""
9+
10+from __future__ import annotations
11+
12+import argparse
13+import json
14+import re
15+import shutil
16+
17+# nosec B404 - running qlty *is* this script's job. Every command it launches is a
18+# literal argv list built in this file; none is assembled from user input, and none
19+# goes through a shell. See the matching justification on subprocess.run below.
20+import subprocess # nosec B404
21+import sys
22+from datetime import datetime, timezone
23+from pathlib import Path
24+
25+ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
26+
27+# Overridable per project by writing .quality/gate.json with any subset of these
28+# keys. Kept strict on purpose: the gate is what the refactoring loop drives to
29+# zero. A null value disables that check.
30+DEFAULT_GATE = {
31+ "max_error": 0,
32+ "max_warning": 0,
33+ "max_note": None,
34+ "max_smells": 0,
35+ "max_file_complexity": None,
36+ "max_total_complexity": None,
37+}
38+
39+QLTY_COMMON = ["--no-upgrade-check"]
40+
41+
42+def run(cmd: list[str], cwd: Path, timeout: int = 1800) -> tuple[int, str, str]:
43+ try:
44+ # nosec B603 - cmd is always a literal argv list from this file (qlty or git
45+ # with fixed subcommands), never a string and never shell-interpreted, so
46+ # there is no injection surface. shell=False is the default and is what makes
47+ # this the safe form rather than the risky one.
48+ p = subprocess.run( # nosec B603
49+ cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout
50+ )
51+ except FileNotFoundError:
52+ return 127, "", f"command not found: {cmd[0]}"
53+ except subprocess.TimeoutExpired:
54+ return 124, "", f"timed out after {timeout}s: {' '.join(cmd)}"
55+ return p.returncode, p.stdout, p.stderr
56+
57+
58+def git(args: list[str], cwd: Path) -> str:
59+ code, out, _ = run(["git"] + args, cwd, timeout=60)
60+ return out.strip() if code == 0 else ""
61+
62+
63+def ensure_setup(ws: Path, allow_init: bool) -> None:
64+ """Verify qlty is usable here, running `qlty init` when the project has no config."""
65+ if shutil.which("qlty") is None:
66+ die(
67+ "qlty is not installed. This project expects the `dev-toolkit` sbx kit "
68+ "(see kits/dev-toolkit/README.md)."
69+ )
70+ if not (ws / ".git").exists() and not git(["rev-parse", "--git-dir"], ws):
71+ die(f"{ws} is not a git repository — qlty needs git to scope its analysis.")
72+
73+ if (ws / ".qlty" / "qlty.toml").is_file():
74+ return
75+ if not allow_init:
76+ die("no .qlty/qlty.toml found and --no-init was passed; run `qlty init --yes`.")
77+
78+ log("no .qlty/qlty.toml found — running `qlty init --yes` (first-time setup)")
79+ code, out, err = run(["qlty", "init", "--yes"] + QLTY_COMMON, ws, timeout=1800)
80+ if code != 0 or not (ws / ".qlty" / "qlty.toml").is_file():
81+ die(f"`qlty init` failed (exit {code}).\n{(out + err).strip()[-2000:]}")
82+ log("qlty initialised: .qlty/qlty.toml written")
83+
84+
85+def sarif_location(res: dict) -> tuple[str, int | None]:
86+ """Return the file and start line of a SARIF result, or ("", None) if it has none.
87+
88+ Every level of the structure is optional in the spec and plugins do omit them,
89+ hence the `or {}` at each step rather than a single chained access.
90+ """
91+ locs = res.get("locations") or []
92+ if not locs:
93+ return "", None
94+ phys = (locs[0] or {}).get("physicalLocation") or {}
95+ uri = ((phys.get("artifactLocation") or {}).get("uri")) or ""
96+ return uri, (phys.get("region") or {}).get("startLine")
97+
98+
99+def sarif_finding(res: dict) -> dict:
100+ """Flatten one SARIF result into the shape the rest of this script uses."""
101+ uri, line = sarif_location(res)
102+ return {
103+ "rule": res.get("ruleId") or "unknown",
104+ # SARIF omits `level` when it equals the rule default; treat an absent
105+ # level as "warning" rather than dropping the finding.
106+ "level": (res.get("level") or "warning").lower(),
107+ "message": ((res.get("message") or {}).get("text") or "").strip(),
108+ "file": uri,
109+ "line": line,
110+ }
111+
112+
113+def sarif_results(payload: str) -> list[dict]:
114+ """Flatten SARIF runs into a list of result dicts, tolerating partial output."""
115+ try:
116+ doc = json.loads(payload)
117+ except json.JSONDecodeError:
118+ return []
119+ return [
120+ sarif_finding(res)
121+ for run_ in doc.get("runs") or []
122+ for res in run_.get("results") or []
123+ ]
124+
125+
126+def collect_check(ws: Path) -> tuple[list[dict], str]:
127+ # --no-fail / --no-error: always emit a report instead of exiting on findings.
128+ # --no-fix: measurement must not mutate the tree.
129+ cmd = [
130+ "qlty", "check", "--all", "--no-fix", "--no-fail", "--no-error",
131+ "--no-progress", "--sarif",
132+ ] + QLTY_COMMON
133+ code, out, err = run(cmd, ws)
134+ results = sarif_results(out)
135+ note = "" if (results or code == 0) else f"`qlty check` exit {code}: {err.strip()[-500:]}"
136+ return results, note
137+
138+
139+def collect_smells(ws: Path) -> tuple[list[dict], str]:
140+ cmd = ["qlty", "smells", "--all", "--quiet", "--sarif"] + QLTY_COMMON
141+ code, out, err = run(cmd, ws)
142+ results = sarif_results(out)
143+ note = "" if (results or code == 0) else f"`qlty smells` exit {code}: {err.strip()[-500:]}"
144+ return results, note
145+
146+
147+def table_cells(text: str) -> list[list[str]]:
148+ """Split a pipe table into rows of cells, dropping rules and blank lines.
149+
150+ qlty renders an ANSI-coloured table and ignores NO_COLOR, so the escape codes
151+ are stripped here rather than by the caller.
152+ """
153+ rows: list[list[str]] = []
154+ for raw in text.splitlines():
155+ line = ANSI.sub("", raw).strip()
156+ if "|" not in line or set(line) <= set("-+| "):
157+ continue
158+ rows.append([c.strip() for c in line.split("|")])
159+ return rows
160+
161+
162+def metric_row(header: list[str], cells: list[str]) -> dict:
163+ """Pair a data row with its header, keeping `name` textual and the rest numeric."""
164+ row: dict = {}
165+ for key, val in zip(header, cells):
166+ if key == "name":
167+ row["name"] = val
168+ continue
169+ try:
170+ row[key] = int(val)
171+ except ValueError:
172+ row[key] = val
173+ return row
174+
175+
176+def parse_metrics_table(text: str) -> tuple[dict, list[dict]]:
177+ """Split qlty's metrics table into its TOTAL row and its per-file rows.
178+
179+ A row whose width does not match the header is skipped rather than guessed at:
180+ qlty occasionally wraps a long path, and inventing a value would silently
181+ corrupt the numbers this whole report rests on.
182+ """
183+ header: list[str] = []
184+ total: dict = {}
185+ rows: list[dict] = []
186+ for cells in table_cells(text):
187+ if not header:
188+ header = [c.lower() for c in cells]
189+ continue
190+ if len(cells) != len(header):
191+ continue
192+ row = metric_row(header, cells)
193+ if row.get("name", "").upper() == "TOTAL":
194+ total = {k: v for k, v in row.items() if k != "name"}
195+ else:
196+ rows.append(row)
197+ return total, rows
198+
199+
200+def collect_metrics(ws: Path) -> tuple[dict, list[dict], str]:
201+ """Run `qlty metrics` and return its TOTAL row, its per-file rows, and any note."""
202+ cmd = ["qlty", "metrics", "--all", "--quiet"] + QLTY_COMMON
203+ code, out, err = run(cmd, ws)
204+ if code != 0 and not out.strip():
205+ return {}, [], f"`qlty metrics` exit {code}: {err.strip()[-500:]}"
206+
207+ total, rows = parse_metrics_table(out)
208+ return total, rows, ""
209+
210+
211+def load_gate(ws: Path, override: Path | None) -> dict:
212+ gate = dict(DEFAULT_GATE)
213+ path = override or (ws / ".quality" / "gate.json")
214+ if path.is_file():
215+ try:
216+ gate.update(json.loads(path.read_text()))
217+ except (json.JSONDecodeError, OSError) as exc:
218+ die(f"could not read gate file {path}: {exc}")
219+ return gate
220+
221+
222+def evaluate(gate: dict, counts: dict, smells: int, metrics: dict, files: list[dict]) -> list[str]:
223+ """Return one human-readable violation string per breached threshold."""
224+ breaches: list[str] = []
225+
226+ def check(limit_key: str, actual: int, label: str) -> None:
227+ limit = gate.get(limit_key)
228+ if limit is not None and actual > limit:
229+ breaches.append(f"{label}: {actual} (max {limit})")
230+
231+ check("max_error", counts.get("error", 0), "error-level issues")
232+ check("max_warning", counts.get("warning", 0), "warning-level issues")
233+ check("max_note", counts.get("note", 0), "note-level issues")
234+ check("max_smells", smells, "code smells")
235+ check("max_total_complexity", metrics.get("complex", 0), "total complexity")
236+
237+ limit = gate.get("max_file_complexity")
238+ if limit is not None:
239+ over = [f for f in files if isinstance(f.get("complex"), int) and f["complex"] > limit]
240+ for f in sorted(over, key=lambda r: -r["complex"])[:10]:
241+ breaches.append(f"{f['name']} complexity {f['complex']} (max {limit})")
242+ return breaches
243+
244+
245+def tally(results: list[dict], key: str) -> dict[str, int]:
246+ counts: dict[str, int] = {}
247+ for r in results:
248+ counts[r[key]] = counts.get(r[key], 0) + 1
249+ return counts
250+
251+
252+def log(msg: str) -> None:
253+ print(f"[quality] {msg}", file=sys.stderr)
254+
255+
256+def die(msg: str) -> None:
257+ print(f"[quality] error: {msg}", file=sys.stderr)
258+ sys.exit(2)
259+
260+
261+def md_table(headers: list[str], rows: list[list[str]]) -> str:
262+ if not rows:
263+ return "_none_\n"
264+
265+ out = ["| " + " | ".join(headers) + " |", "|" + "|".join(["---"] * len(headers)) + "|"]
266+ out += ["| " + " | ".join(rows_) + " |" for rows_ in (map(str, r) for r in rows)]
267+ return "\n".join(out) + "\n"
268+
269+
270+def delta(cur: int, prev: int | None) -> str:
271+ if prev is None:
272+ return "—"
273+ d = cur - prev
274+ if d == 0:
275+ return "±0"
276+ return f"{d:+d}"
277+
278+
279+def report_header(entry: dict, prev: dict | None, breaches: list[str]) -> list[str]:
280+ verdict = "✅ **PASS**" if entry["gate_passed"] else "❌ **FAIL**"
281+ lines = [
282+ f"# Quality report — {entry['timestamp']}",
283+ "",
284+ f"- **Gate**: {verdict}",
285+ f"- **Commit**: `{entry['commit'] or 'n/a'}` on `{entry['branch'] or 'n/a'}`",
286+ f"- **qlty**: {entry['qlty_version']}",
287+ f"- **Run**: #{entry['run']}"
288+ + (f" (previous: {prev['timestamp']})" if prev else " (first recorded run)"),
289+ "",
290+ ]
291+ if breaches:
292+ lines += ["## Gate violations", ""]
293+ lines += [f"- {b}" for b in breaches]
294+ lines.append("")
295+ return lines
296+
297+
298+def report_issues(entry: dict, prev: dict | None, check_res: list[dict]) -> list[str]:
299+ """The lint section: counts by level, then the two "where to start" tables."""
300+ c = entry["counts"]
301+ p_counts = (prev or {}).get("counts") or {}
302+
303+ lines = ["## Lint issues (`qlty check`)", ""]
304+ lines.append(
305+ md_table(
306+ ["level", "count", "vs previous"],
307+ [[lvl, c.get(lvl, 0), delta(c.get(lvl, 0), p_counts.get(lvl))]
308+ for lvl in ("error", "warning", "note", "none")
309+ if c.get(lvl) or p_counts.get(lvl)],
310+ )
311+ )
312+
313+ by_rule = sorted(tally(check_res, "rule").items(), key=lambda kv: -kv[1])[:15]
314+ lines += ["### Top rules", "", md_table(["rule", "count"], [[k, v] for k, v in by_rule])]
315+
316+ by_file = sorted(tally([r for r in check_res if r["file"]], "file").items(),
317+ key=lambda kv: -kv[1])[:15]
318+ lines += ["### Most affected files", "",
319+ md_table(["file", "issues"], [[k, v] for k, v in by_file])]
320+ return lines
321+
322+
323+def report_smells(entry: dict, prev: dict | None, smell_res: list[dict]) -> list[str]:
324+ lines = ["## Code smells (`qlty smells`)", "",
325+ f"Total: **{entry['smells']}** (vs previous: "
326+ f"{delta(entry['smells'], (prev or {}).get('smells'))})", ""]
327+ smell_rows = [[r["rule"], r["file"] or "—", r["line"] or "—", r["message"][:110]]
328+ for r in smell_res[:20]]
329+ lines.append(md_table(["smell", "file", "line", "detail"], smell_rows))
330+ return lines
331+
332+
333+def report_metrics(entry: dict, prev: dict | None, files: list[dict]) -> list[str]:
334+ m = entry["metrics"]
335+ p_metrics = (prev or {}).get("metrics") or {}
336+
337+ lines = ["## Metrics (`qlty metrics`)", ""]
338+ metric_rows = [
339+ [k, m.get(k, "—"), delta(m[k], p_metrics.get(k)) if isinstance(m.get(k), int) else "—"]
340+ for k in ("funcs", "classes", "fields", "cyclo", "complex", "lcom", "lines", "loc")
341+ if k in m
342+ ]
343+ lines.append(md_table(["metric", "total", "vs previous"], metric_rows))
344+
345+ worst = sorted([f for f in files if isinstance(f.get("complex"), int)],
346+ key=lambda r: -r["complex"])[:15]
347+ lines += ["### Most complex files", "",
348+ md_table(["file", "complex", "cyclo", "loc"],
349+ [[f["name"], f.get("complex", "—"), f.get("cyclo", "—"),
350+ f.get("loc", "—")] for f in worst])]
351+ return lines
352+
353+
354+def report_trend(entry: dict) -> list[str]:
355+ return ["## Trend", "", md_table(
356+ ["run", "timestamp", "error", "warning", "smells", "complex", "gate"],
357+ [[h["run"], h["timestamp"], (h.get("counts") or {}).get("error", 0),
358+ (h.get("counts") or {}).get("warning", 0), h.get("smells", 0),
359+ (h.get("metrics") or {}).get("complex", "—"),
360+ "PASS" if h.get("gate_passed") else "FAIL"]
361+ for h in entry["_trend"]],
362+ )]
363+
364+
365+def build_report(entry: dict, prev: dict | None, check_res: list[dict],
366+ smell_res: list[dict], files: list[dict], breaches: list[str],
367+ notes: list[str]) -> str:
368+ """Assemble the Markdown report, in the order a reader works through it."""
369+ lines = report_header(entry, prev, breaches)
370+ lines += report_issues(entry, prev, check_res)
371+ lines += report_smells(entry, prev, smell_res)
372+ lines += report_metrics(entry, prev, files)
373+
374+ if notes:
375+ lines += ["## Tooling notes", ""] + [f"- {n}" for n in notes] + [""]
376+
377+ lines += report_trend(entry)
378+ return "\n".join(lines)
379+
380+
381+def main() -> int:
382+ ap = argparse.ArgumentParser(description=__doc__)
383+ ap.add_argument("--workspace", default=".", help="project root (default: cwd)")
384+ ap.add_argument("--no-init", action="store_true",
385+ help="fail instead of running `qlty init` when unconfigured")
386+ ap.add_argument("--gate", type=Path, help="gate JSON file (default: .quality/gate.json)")
387+ ap.add_argument("--json", action="store_true", help="print the history entry to stdout")
388+ args = ap.parse_args()
389+
390+ ws = Path(args.workspace).resolve()
391+ if not ws.is_dir():
392+ die(f"workspace {ws} does not exist")
393+
394+ ensure_setup(ws, allow_init=not args.no_init)
395+
396+ notes: list[str] = []
397+ log("running qlty check / smells / metrics (this can take several minutes on a cold cache)")
398+ check_res, n1 = collect_check(ws)
399+ smell_res, n2 = collect_smells(ws)
400+ metrics, files, n3 = collect_metrics(ws)
401+ notes += [n for n in (n1, n2, n3) if n]
402+
403+ counts = tally(check_res, "level")
404+ gate = load_gate(ws, args.gate)
405+ breaches = evaluate(gate, counts, len(smell_res), metrics, files)
406+
407+ _, ver_out, _ = run(["qlty", "--version"], ws, timeout=60)
408+ outdir = ws / ".quality"
409+ outdir.mkdir(parents=True, exist_ok=True)
410+ history = outdir / "history.jsonl"
411+
412+ past: list[dict] = []
413+ if history.is_file():
414+ for line in history.read_text().splitlines():
415+ line = line.strip()
416+ if line:
417+ try:
418+ past.append(json.loads(line))
419+ except json.JSONDecodeError:
420+ continue
421+
422+ now = datetime.now(timezone.utc)
423+ entry = {
424+ "run": len(past) + 1,
425+ "timestamp": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
426+ "commit": git(["rev-parse", "--short", "HEAD"], ws),
427+ "branch": git(["rev-parse", "--abbrev-ref", "HEAD"], ws),
428+ "qlty_version": ANSI.sub("", ver_out).strip() or "unknown",
429+ "counts": counts,
430+ "smells": len(smell_res),
431+ "metrics": metrics,
432+ "gate": {k: v for k, v in gate.items() if v is not None},
433+ "gate_passed": not breaches,
434+ "breaches": breaches,
435+ }
436+
437+ with history.open("a") as fh:
438+ fh.write(json.dumps(entry, sort_keys=True) + "\n")
439+
440+ entry["_trend"] = (past + [entry])[-10:]
441+ report = build_report(entry, past[-1] if past else None,
442+ check_res, smell_res, files, breaches, notes)
443+ del entry["_trend"]
444+
445+ stamp = now.strftime("%Y%m%dT%H%M%SZ")
446+ (outdir / f"report-{stamp}.md").write_text(report)
447+ (outdir / "report-latest.md").write_text(report)
448+
449+ print(report)
450+ log(f"report written to .quality/report-{stamp}.md (and report-latest.md)")
451+ log(f"history appended to .quality/history.jsonl (run #{entry['run']})")
452+ if args.json:
453+ print(json.dumps(entry, indent=2, sort_keys=True))
454+
455+ if breaches:
456+ log(f"GATE FAILED — {len(breaches)} violation(s)")
457+ return 1
458+ log("GATE PASSED")
459+ return 0
460+
461+
462+if __name__ == "__main__":
463+ sys.exit(main())
modified .tickets/issues/0021-when-there-is-an-error-durring-the-completion-we.yaml +80 -3
@@ -6,10 +6,87 @@ author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-09-17T13:31:10.696Z
9-updatedAt: 2026-09-17T13:34:49.484Z
9+updatedAt: 2026-09-18T14:20:00.000Z
1010 labels:
1111 - priority::urgent
1212 milestone: v0.0.2
13-body: ""
14-tasks: []
13+body: |
14+ ## Problem
15+
16+ When a completion fails — server down, model not pulled, watchdog timeout, empty or malformed compaction summary — the front end shows one red line (`[error: …]` in the terminal and the window, a JSON-RPC error in the editor) and that is the only trace. It goes with the scrollback and is absent from the session file, because the file holds what the model sees and a failed turn leaves nothing for it (the unanswered question is dropped by `session.AfterTurn`). Nothing is left to hand to a coding agent asked "why did this fail, and what should change in the code or the YAML?".
17+
18+ ## Implementation note (Claude, 2026-09-18, branch `feature/log-errors`, not yet committed)
19+
20+ The session file (`sessions.enabled: true`, `<project>/.mm/sessions/<id>.json`) now carries an `errors` array next to `messages`, one record per error shown:
21+
22+ ```json
23+ {
24+ "at": "2026-09-18T14:12:25Z",
25+ "turn": 1,
26+ "where": "generation",
27+ "message": "dmr: \"no/such-model:none\" not found at http://…/engines/v1 — docker model pull no/such-model:none",
28+ "detail": "failed to create completion: POST \"…/chat/completions\": 404 Not Found",
29+ "question": "hello"
30+ }
31+ ```
32+
33+ - `where` is `generation` (a failed answer, `engine.Generate`), `compaction` (a failed summary, automatic or `/compact`) or `resume` (a `/resume` that could not load its session). A cancelled turn (Esc, Ctrl-C, new question) is not recorded.
34+ - `message` is the line the user saw (`engine.Explain`'s words); `detail` is the raw Go error, only when it says more; `question` is the first line of the question being answered — stamped from the history *before* `AfterTurn` dropped it; `turn` counts the user questions at that point.
35+ - Recorded by all three front ends: window (`internal/tui/turn.go`, `runner.record`), REPL (`internal/agent/agent.go`, `record`), ACP (`internal/acp/persist.go`, `front.fail`). Code: `store.Failure`, `store.NewFailure`, `(*store.Session).Fail`, `(*store.Keeper).Fail` in `internal/store`.
36+ - Reload safety: `/resume` and `session/load` read `messages` alone, so the array is never replayed and never reaches the model; a file written before this change loads with no errors (`omitempty`, tested); `/compact` rewrites `messages` and leaves the trail; over ACP `/new` moves the trail to the archived conversation. The trail is capped at 200 records.
37+ - Visible: the `/sessions` listing and the `↩️ resumed …` report end with ` · ⚠️ N error(s)` when a session has recorded errors. A session whose only content is an error has 0 turns: a bare `/resume` skips it (existing rule), `/resume <id>` reaches it.
38+ - Workflow: `jq .errors .mm/sessions/<id>.json`, or hand the file to a coding agent and ask what went wrong and what to change (`watchdogTimeout`, `maxOutput`, `model`, `context.*`, or the code).
39+
40+ Tested: `go test ./...` (new tests in `internal/store`, `internal/tui`, `internal/acp`); end to end from the sandbox with `agent.dmr.yaml` against the host's DMR (`host.docker.internal:12434`): an unknown model name records a `generation` error in the REPL and in the window mode (pseudo-terminal), `/resume <id>` of that session then a real answer keeps the trail. Docs EN+FR updated (`explanation/persistent-sessions.md`, `how-to/keep-sessions.md`, `reference/slash-commands.md`, `reference/configuration.md`). Quality gate: no new finding in the touched files.
41+
42+ Not done: a slash command to show the errors from inside `mm` (jq or an agent does it); errors from tools (they return text to the model, by design, never a Go error) are not recorded — they are already in the history.
43+tasks:
44+ - id: 1
45+ title: "internal/store: `errors` array in the session file, outside `messages` (Failure, NewFailure, Session.Fail, Keeper.Fail), capped at 200"
46+ state: closed
47+ priority: none
48+ author:
49+ name: k33g
50+ email: ph.charriere@gmail.com
51+ createdAt: 2026-09-18T14:20:00.000Z
52+ updatedAt: 2026-09-18T14:20:00.000Z
53+ closedAt: 2026-09-18T14:20:00.000Z
54+ - id: 2
55+ title: "Record failed generations, failed compactions and failed /resume in the window mode, the REPL and over ACP; cancelled turns are not errors"
56+ state: closed
57+ priority: none
58+ author:
59+ name: k33g
60+ email: ph.charriere@gmail.com
61+ createdAt: 2026-09-18T14:20:00.000Z
62+ updatedAt: 2026-09-18T14:20:00.000Z
63+ closedAt: 2026-09-18T14:20:00.000Z
64+ - id: 3
65+ title: "Reload safety: /resume and session/load ignore the array; old files load; /compact keeps it; ACP /new archives it with its conversation — tests"
66+ state: closed
67+ priority: none
68+ author:
69+ name: k33g
70+ email: ph.charriere@gmail.com
71+ createdAt: 2026-09-18T14:20:00.000Z
72+ updatedAt: 2026-09-18T14:20:00.000Z
73+ closedAt: 2026-09-18T14:20:00.000Z
74+ - id: 4
75+ title: "/sessions listing and /resume report show ` · ⚠️ N error(s)`; docs EN+FR (explanation, how-to, slash-commands, configuration)"
76+ state: closed
77+ priority: none
78+ author:
79+ name: k33g
80+ email: ph.charriere@gmail.com
81+ createdAt: 2026-09-18T14:20:00.000Z
82+ updatedAt: 2026-09-18T14:20:00.000Z
83+ closedAt: 2026-09-18T14:20:00.000Z
84+ - id: 5
85+ title: "Verify on macOS with the installed binary (`./install.sh`): trigger a red error in `mm -tui`, then `jq .errors` on the session file and `/resume <id>`"
86+ state: open
87+ priority: medium
88+ author:
89+ name: k33g
90+ email: ph.charriere@gmail.com
91+ createdAt: 2026-09-18T14:20:00.000Z
1592 comments: []
@@ -6,10 +6,87 @@ author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-09-17T13:31:10.696Z8 createdAt: 2026-09-17T13:31:10.696Z
9-updatedAt: 2026-09-17T13:34:49.484Z9+updatedAt: 2026-09-18T14:20:00.000Z
10 labels:10 labels:
11 - priority::urgent11 - priority::urgent
12 milestone: v0.0.212 milestone: v0.0.2
13-body: ""13+body: |
14-tasks: []14+ ## Problem
15+
16+ When a completion fails — server down, model not pulled, watchdog timeout, empty or malformed compaction summary — the front end shows one red line (`[error: …]` in the terminal and the window, a JSON-RPC error in the editor) and that is the only trace. It goes with the scrollback and is absent from the session file, because the file holds what the model sees and a failed turn leaves nothing for it (the unanswered question is dropped by `session.AfterTurn`). Nothing is left to hand to a coding agent asked "why did this fail, and what should change in the code or the YAML?".
17+
18+ ## Implementation note (Claude, 2026-09-18, branch `feature/log-errors`, not yet committed)
19+
20+ The session file (`sessions.enabled: true`, `<project>/.mm/sessions/<id>.json`) now carries an `errors` array next to `messages`, one record per error shown:
21+
22+ ```json
23+ {
24+ "at": "2026-09-18T14:12:25Z",
25+ "turn": 1,
26+ "where": "generation",
27+ "message": "dmr: \"no/such-model:none\" not found at http://…/engines/v1 — docker model pull no/such-model:none",
28+ "detail": "failed to create completion: POST \"…/chat/completions\": 404 Not Found",
29+ "question": "hello"
30+ }
31+ ```
32+
33+ - `where` is `generation` (a failed answer, `engine.Generate`), `compaction` (a failed summary, automatic or `/compact`) or `resume` (a `/resume` that could not load its session). A cancelled turn (Esc, Ctrl-C, new question) is not recorded.
34+ - `message` is the line the user saw (`engine.Explain`'s words); `detail` is the raw Go error, only when it says more; `question` is the first line of the question being answered — stamped from the history *before* `AfterTurn` dropped it; `turn` counts the user questions at that point.
35+ - Recorded by all three front ends: window (`internal/tui/turn.go`, `runner.record`), REPL (`internal/agent/agent.go`, `record`), ACP (`internal/acp/persist.go`, `front.fail`). Code: `store.Failure`, `store.NewFailure`, `(*store.Session).Fail`, `(*store.Keeper).Fail` in `internal/store`.
36+ - Reload safety: `/resume` and `session/load` read `messages` alone, so the array is never replayed and never reaches the model; a file written before this change loads with no errors (`omitempty`, tested); `/compact` rewrites `messages` and leaves the trail; over ACP `/new` moves the trail to the archived conversation. The trail is capped at 200 records.
37+ - Visible: the `/sessions` listing and the `↩️ resumed …` report end with ` · ⚠️ N error(s)` when a session has recorded errors. A session whose only content is an error has 0 turns: a bare `/resume` skips it (existing rule), `/resume <id>` reaches it.
38+ - Workflow: `jq .errors .mm/sessions/<id>.json`, or hand the file to a coding agent and ask what went wrong and what to change (`watchdogTimeout`, `maxOutput`, `model`, `context.*`, or the code).
39+
40+ Tested: `go test ./...` (new tests in `internal/store`, `internal/tui`, `internal/acp`); end to end from the sandbox with `agent.dmr.yaml` against the host's DMR (`host.docker.internal:12434`): an unknown model name records a `generation` error in the REPL and in the window mode (pseudo-terminal), `/resume <id>` of that session then a real answer keeps the trail. Docs EN+FR updated (`explanation/persistent-sessions.md`, `how-to/keep-sessions.md`, `reference/slash-commands.md`, `reference/configuration.md`). Quality gate: no new finding in the touched files.
41+
42+ Not done: a slash command to show the errors from inside `mm` (jq or an agent does it); errors from tools (they return text to the model, by design, never a Go error) are not recorded — they are already in the history.
43+tasks:
44+ - id: 1
45+ title: "internal/store: `errors` array in the session file, outside `messages` (Failure, NewFailure, Session.Fail, Keeper.Fail), capped at 200"
46+ state: closed
47+ priority: none
48+ author:
49+ name: k33g
50+ email: ph.charriere@gmail.com
51+ createdAt: 2026-09-18T14:20:00.000Z
52+ updatedAt: 2026-09-18T14:20:00.000Z
53+ closedAt: 2026-09-18T14:20:00.000Z
54+ - id: 2
55+ title: "Record failed generations, failed compactions and failed /resume in the window mode, the REPL and over ACP; cancelled turns are not errors"
56+ state: closed
57+ priority: none
58+ author:
59+ name: k33g
60+ email: ph.charriere@gmail.com
61+ createdAt: 2026-09-18T14:20:00.000Z
62+ updatedAt: 2026-09-18T14:20:00.000Z
63+ closedAt: 2026-09-18T14:20:00.000Z
64+ - id: 3
65+ title: "Reload safety: /resume and session/load ignore the array; old files load; /compact keeps it; ACP /new archives it with its conversation — tests"
66+ state: closed
67+ priority: none
68+ author:
69+ name: k33g
70+ email: ph.charriere@gmail.com
71+ createdAt: 2026-09-18T14:20:00.000Z
72+ updatedAt: 2026-09-18T14:20:00.000Z
73+ closedAt: 2026-09-18T14:20:00.000Z
74+ - id: 4
75+ title: "/sessions listing and /resume report show ` · ⚠️ N error(s)`; docs EN+FR (explanation, how-to, slash-commands, configuration)"
76+ state: closed
77+ priority: none
78+ author:
79+ name: k33g
80+ email: ph.charriere@gmail.com
81+ createdAt: 2026-09-18T14:20:00.000Z
82+ updatedAt: 2026-09-18T14:20:00.000Z
83+ closedAt: 2026-09-18T14:20:00.000Z
84+ - id: 5
85+ title: "Verify on macOS with the installed binary (`./install.sh`): trigger a red error in `mm -tui`, then `jq .errors` on the session file and `/resume <id>`"
86+ state: open
87+ priority: medium
88+ author:
89+ name: k33g
90+ email: ph.charriere@gmail.com
91+ createdAt: 2026-09-18T14:20:00.000Z
15 comments: []92 comments: []
modified agent.dmr.yaml +1 -1
@@ -34,7 +34,7 @@ skillsDir: skills
3434 # Persistent sessions, per project.
3535 # When enabled, every conversation is saved as one JSON file under `dir`
3636 sessions:
37- enabled: false
37+ enabled: true
3838 dir: .mm/sessions
3939
4040 # How many lines of a command's output are echoed to the terminal.
@@ -34,7 +34,7 @@ skillsDir: skills
34 # Persistent sessions, per project. 34 # Persistent sessions, per project.
35 # When enabled, every conversation is saved as one JSON file under `dir` 35 # When enabled, every conversation is saved as one JSON file under `dir`
36 sessions:36 sessions:
37- enabled: false37+ enabled: true
38 dir: .mm/sessions38 dir: .mm/sessions
39 39
40 # How many lines of a command's output are echoed to the terminal. 40 # How many lines of a command's output are echoed to the terminal.
modified docs/en/explanation/persistent-sessions.md +9 -0
@@ -28,6 +28,14 @@ A reloaded or resumed session continues under the current system prompt, not the
2828
2929 In the terminal `/new` simply leaves the old file alone and lets the next question open a new one. Over ACP the editor owns the session id and keeps using it, so the id's file must become the fresh session; the conversation so far is moved to a new id first. Either way nothing is lost on disk, and a later reload of the thread gives the current conversation, not the one `/new` closed.
3030
31+## Why errors are recorded next to the history, not in it
32+
33+A turn can fail — the server is down, the model is not pulled, the watchdog gave up, the summary of a compaction came back empty — and the front end says so in one red line. That line was the only trace: gone with the scrollback, absent from the file, because the file holds what the model sees and a failed turn leaves nothing for the model to see (the unanswered question is dropped). Since 2026-09-18 the session file also carries an `errors` array: one record per line shown, with the time, the turn count, the first line of the question being answered, the line as shown, and the raw error when it says more. Three kinds are recorded: `generation` (a failed answer), `compaction` (a failed summary, automatic or `/compact`) and `resume` (a `/resume` that could not load its session). A cancelled turn is not an error and is not recorded.
34+
35+The array sits beside `messages`, not inside it, on purpose. The model never reads it: an error is about the run, not about the project, and feeding it back would make the model explain its infrastructure instead of doing its task. `/resume` and `session/load` never replay it: both read `messages` alone, so an old session loads exactly as before and a new one with errors loads as if the field were not there. And compression leaves it alone: `/compact` rewrites `messages`, the trail stays whole. The record follows its conversation: over ACP, `/new` moves it to the archived file with the history it belongs to.
36+
37+What it is for: asking afterwards, with the file in hand, why a run went wrong — `jq .errors <file>` for a human, or the file handed to a coding agent asked to explain the failures and propose changes to the code or the YAML (a longer `watchdogTimeout`, a different model, a smaller `maxOutput`). The `/sessions` listing and the `/resume` report show ` · ⚠️ N error(s)` so a session worth investigating stands out. The trail is capped at 200 records, oldest first out, so a run that fails on every turn does not grow its file without end.
38+
3139 ## Rejected alternatives
3240
3341 - **Resume the latest session automatically at start.** Rejected for the stale-context reason above, and because docker agent does not do it either.
@@ -35,6 +43,7 @@ In the terminal `/new` simply leaves the old file alone and lets the next questi
3543 - **SQLite, like docker agent.** Rejected for a minimal agent: one more dependency, opaque files, and a per-user database where mini-me wants per-project files.
3644 - **`session/resume` (unstable) instead of `session/load`.** Rejected: not in the stable spec, and it relies on the editor having kept the thread.
3745 - **Replaying only text messages.** Rejected by the user: the tool calls are the part of a coding session worth seeing again.
46+- **Recording errors as messages in the history.** Rejected: the model would read them, `session/load` would have to replay them as something, and an old file would load differently from a new one. A separate field costs nothing on load and changes nothing for the model.
3847
3948 ## How it relates to the rest
4049
@@ -28,6 +28,14 @@ A reloaded or resumed session continues under the current system prompt, not the
28 28
29 In the terminal `/new` simply leaves the old file alone and lets the next question open a new one. Over ACP the editor owns the session id and keeps using it, so the id's file must become the fresh session; the conversation so far is moved to a new id first. Either way nothing is lost on disk, and a later reload of the thread gives the current conversation, not the one `/new` closed.29 In the terminal `/new` simply leaves the old file alone and lets the next question open a new one. Over ACP the editor owns the session id and keeps using it, so the id's file must become the fresh session; the conversation so far is moved to a new id first. Either way nothing is lost on disk, and a later reload of the thread gives the current conversation, not the one `/new` closed.
30 30
31+## Why errors are recorded next to the history, not in it
32+
33+A turn can fail — the server is down, the model is not pulled, the watchdog gave up, the summary of a compaction came back empty — and the front end says so in one red line. That line was the only trace: gone with the scrollback, absent from the file, because the file holds what the model sees and a failed turn leaves nothing for the model to see (the unanswered question is dropped). Since 2026-09-18 the session file also carries an `errors` array: one record per line shown, with the time, the turn count, the first line of the question being answered, the line as shown, and the raw error when it says more. Three kinds are recorded: `generation` (a failed answer), `compaction` (a failed summary, automatic or `/compact`) and `resume` (a `/resume` that could not load its session). A cancelled turn is not an error and is not recorded.
34+
35+The array sits beside `messages`, not inside it, on purpose. The model never reads it: an error is about the run, not about the project, and feeding it back would make the model explain its infrastructure instead of doing its task. `/resume` and `session/load` never replay it: both read `messages` alone, so an old session loads exactly as before and a new one with errors loads as if the field were not there. And compression leaves it alone: `/compact` rewrites `messages`, the trail stays whole. The record follows its conversation: over ACP, `/new` moves it to the archived file with the history it belongs to.
36+
37+What it is for: asking afterwards, with the file in hand, why a run went wrong — `jq .errors <file>` for a human, or the file handed to a coding agent asked to explain the failures and propose changes to the code or the YAML (a longer `watchdogTimeout`, a different model, a smaller `maxOutput`). The `/sessions` listing and the `/resume` report show ` · ⚠️ N error(s)` so a session worth investigating stands out. The trail is capped at 200 records, oldest first out, so a run that fails on every turn does not grow its file without end.
38+
31 ## Rejected alternatives39 ## Rejected alternatives
32 40
33 - **Resume the latest session automatically at start.** Rejected for the stale-context reason above, and because docker agent does not do it either.41 - **Resume the latest session automatically at start.** Rejected for the stale-context reason above, and because docker agent does not do it either.
@@ -35,6 +43,7 @@ In the terminal `/new` simply leaves the old file alone and lets the next questi
35 - **SQLite, like docker agent.** Rejected for a minimal agent: one more dependency, opaque files, and a per-user database where mini-me wants per-project files.43 - **SQLite, like docker agent.** Rejected for a minimal agent: one more dependency, opaque files, and a per-user database where mini-me wants per-project files.
36 - **`session/resume` (unstable) instead of `session/load`.** Rejected: not in the stable spec, and it relies on the editor having kept the thread.44 - **`session/resume` (unstable) instead of `session/load`.** Rejected: not in the stable spec, and it relies on the editor having kept the thread.
37 - **Replaying only text messages.** Rejected by the user: the tool calls are the part of a coding session worth seeing again.45 - **Replaying only text messages.** Rejected by the user: the tool calls are the part of a coding session worth seeing again.
46+- **Recording errors as messages in the history.** Rejected: the model would read them, `session/load` would have to replay them as something, and an old file would load differently from a new one. A separate field costs nothing on load and changes nothing for the model.
38 47
39 ## How it relates to the rest48 ## How it relates to the rest
40 49
modified docs/en/how-to/keep-sessions.md +36 -1
@@ -43,6 +43,40 @@ This guide shows how to make `mm` save every conversation of a project and get o
4343
4444 A bare `/resume` takes the most recently updated conversation other than the current one, skipping empty ones; `/resume <id>` names one. From there, the model sees the resumed history — under the current system prompt — and every new turn is saved to that session's file. Nothing is replayed on screen: the one line says what came back.
4545
46+## Find out why a run failed
47+
48+1. Spot the session in the listing: a session that showed at least one error ends its line with ` · ⚠️ N error(s)`. The same tail appears in the `↩️ resumed …` report.
49+
50+ ```text
51+ > /sessions
52+ 2 session(s) in /work/project/.mm/sessions — /resume <id> to continue one, /resume for the latest
53+ * 20260918-141321-db582a3d 2026-09-18 16:13 1 turn(s) say just: ok
54+ 20260918-141225-02790c82 2026-09-18 16:12 0 turn(s) · ⚠️ 1 error(s)
55+ ```
56+
57+2. Read the `errors` array of its file. Each record says when, at which turn, on which question, what the screen showed (`message`) and, when the raw error says more, what it was (`detail`); `where` is `generation`, `compaction` or `resume`:
58+
59+ ```bash
60+ jq .errors .mm/sessions/20260918-141225-02790c82.json
61+ ```
62+
63+ ```json
64+ [
65+ {
66+ "at": "2026-09-18T14:12:25Z",
67+ "turn": 1,
68+ "where": "generation",
69+ "message": "dmr: \"no/such-model:none\" not found at http://localhost:12434/engines/v1 — docker model pull no/such-model:none",
70+ "detail": "failed to create completion: POST \"http://localhost:12434/engines/v1/chat/completions\": 404 Not Found",
71+ "question": "hello"
72+ }
73+ ]
74+ ```
75+
76+3. To have the failure analysed, hand the file to a coding agent (Claude Code, or `mm` itself in another project) and ask what went wrong and what to change — in the code, or in `agent.yaml` (`watchdogTimeout`, `maxOutput`, `model`, `context.*`). The history in the same file gives the context the model had; the `errors` array gives what it never saw.
77+
78+ A session whose only content is an error has no turn: a bare `/resume` skips it, `/resume <id>` reaches it.
79+
4680 ## From an editor (ACP)
4781
4882 1. Keep `sessions.enabled: true` in the file `AGENT_CONFIG` points at. Each thread the editor opens is a session, saved under `<project>/.mm/sessions/<id>.json` from its creation.
@@ -55,7 +89,8 @@ This guide shows how to make `mm` save every conversation of a project and get o
5589
5690 - **One store for every project.** Give `dir` an absolute path. Sessions of different projects then share one directory; each file records its `cwd`.
5791 - **A project you cannot write to.** The banner or the `[acp]` log says `not saved` with the reason; the conversation goes on unpersisted.
58-- **Compression.** A `/compact` or an automatic compression rewrites the file with the shortened history; a resumed or reloaded session starts from the summary, like the live one did.
92+- **Compression.** A `/compact` or an automatic compression rewrites the file with the shortened history; a resumed or reloaded session starts from the summary, like the live one did. The `errors` array is left as it is.
93+- **Errors over ACP.** The editor shows the failure in its own way; the record still goes to the thread's file, and `/new` moves it to the archived conversation with the history it belongs to.
5994 - **Starting fresh on purpose.** Nothing to do: every start is a new session. The old ones only come back on `/resume` or on an editor reload.
6095
6196 ## See also
@@ -43,6 +43,40 @@ This guide shows how to make `mm` save every conversation of a project and get o
43 43
44 A bare `/resume` takes the most recently updated conversation other than the current one, skipping empty ones; `/resume <id>` names one. From there, the model sees the resumed history — under the current system prompt — and every new turn is saved to that session's file. Nothing is replayed on screen: the one line says what came back.44 A bare `/resume` takes the most recently updated conversation other than the current one, skipping empty ones; `/resume <id>` names one. From there, the model sees the resumed history — under the current system prompt — and every new turn is saved to that session's file. Nothing is replayed on screen: the one line says what came back.
45 45
46+## Find out why a run failed
47+
48+1. Spot the session in the listing: a session that showed at least one error ends its line with ` · ⚠️ N error(s)`. The same tail appears in the `↩️ resumed …` report.
49+
50+ ```text
51+ > /sessions
52+ 2 session(s) in /work/project/.mm/sessions — /resume <id> to continue one, /resume for the latest
53+ * 20260918-141321-db582a3d 2026-09-18 16:13 1 turn(s) say just: ok
54+ 20260918-141225-02790c82 2026-09-18 16:12 0 turn(s) · ⚠️ 1 error(s)
55+ ```
56+
57+2. Read the `errors` array of its file. Each record says when, at which turn, on which question, what the screen showed (`message`) and, when the raw error says more, what it was (`detail`); `where` is `generation`, `compaction` or `resume`:
58+
59+ ```bash
60+ jq .errors .mm/sessions/20260918-141225-02790c82.json
61+ ```
62+
63+ ```json
64+ [
65+ {
66+ "at": "2026-09-18T14:12:25Z",
67+ "turn": 1,
68+ "where": "generation",
69+ "message": "dmr: \"no/such-model:none\" not found at http://localhost:12434/engines/v1 — docker model pull no/such-model:none",
70+ "detail": "failed to create completion: POST \"http://localhost:12434/engines/v1/chat/completions\": 404 Not Found",
71+ "question": "hello"
72+ }
73+ ]
74+ ```
75+
76+3. To have the failure analysed, hand the file to a coding agent (Claude Code, or `mm` itself in another project) and ask what went wrong and what to change — in the code, or in `agent.yaml` (`watchdogTimeout`, `maxOutput`, `model`, `context.*`). The history in the same file gives the context the model had; the `errors` array gives what it never saw.
77+
78+ A session whose only content is an error has no turn: a bare `/resume` skips it, `/resume <id>` reaches it.
79+
46 ## From an editor (ACP)80 ## From an editor (ACP)
47 81
48 1. Keep `sessions.enabled: true` in the file `AGENT_CONFIG` points at. Each thread the editor opens is a session, saved under `<project>/.mm/sessions/<id>.json` from its creation.82 1. Keep `sessions.enabled: true` in the file `AGENT_CONFIG` points at. Each thread the editor opens is a session, saved under `<project>/.mm/sessions/<id>.json` from its creation.
@@ -55,7 +89,8 @@ This guide shows how to make `mm` save every conversation of a project and get o
55 89
56 - **One store for every project.** Give `dir` an absolute path. Sessions of different projects then share one directory; each file records its `cwd`.90 - **One store for every project.** Give `dir` an absolute path. Sessions of different projects then share one directory; each file records its `cwd`.
57 - **A project you cannot write to.** The banner or the `[acp]` log says `not saved` with the reason; the conversation goes on unpersisted.91 - **A project you cannot write to.** The banner or the `[acp]` log says `not saved` with the reason; the conversation goes on unpersisted.
58-- **Compression.** A `/compact` or an automatic compression rewrites the file with the shortened history; a resumed or reloaded session starts from the summary, like the live one did.92+- **Compression.** A `/compact` or an automatic compression rewrites the file with the shortened history; a resumed or reloaded session starts from the summary, like the live one did. The `errors` array is left as it is.
93+- **Errors over ACP.** The editor shows the failure in its own way; the record still goes to the thread's file, and `/new` moves it to the archived conversation with the history it belongs to.
59 - **Starting fresh on purpose.** Nothing to do: every start is a new session. The old ones only come back on `/resume` or on an editor reload.94 - **Starting fresh on purpose.** Nothing to do: every start is a new session. The old ones only come back on `/resume` or on an editor reload.
60 95
61 ## See also96 ## See also
modified docs/en/reference/configuration.md +1 -1
@@ -43,7 +43,7 @@
4343
4444 | Key | Type | Default | Description |
4545 |-----|------|---------|-------------|
46-| `sessions.enabled` | bool | `false` | Save every conversation to a file, rewritten after each turn, `/compact` and `/new`. Enables `/sessions` and `/resume` in the terminal and `session/load` over ACP. |
46+| `sessions.enabled` | bool | `false` | Save every conversation to a file, rewritten after each turn, `/compact` and `/new`. The file also records, outside the history, every error shown (`errors` array: failed generation, failed compaction, failed `/resume`). Enables `/sessions` and `/resume` in the terminal and `session/load` over ACP. |
4747 | `sessions.dir` | string | `.mm/sessions` | Directory of the session files, one `<id>.json` each. Relative to the directory `mm` is started from (terminal) or to the session's `cwd` (ACP); an absolute path is used as-is. Must not be empty. |
4848
4949 ## Provider defaults
@@ -43,7 +43,7 @@
43 43
44 | Key | Type | Default | Description |44 | Key | Type | Default | Description |
45 |-----|------|---------|-------------|45 |-----|------|---------|-------------|
46-| `sessions.enabled` | bool | `false` | Save every conversation to a file, rewritten after each turn, `/compact` and `/new`. Enables `/sessions` and `/resume` in the terminal and `session/load` over ACP. |46+| `sessions.enabled` | bool | `false` | Save every conversation to a file, rewritten after each turn, `/compact` and `/new`. The file also records, outside the history, every error shown (`errors` array: failed generation, failed compaction, failed `/resume`). Enables `/sessions` and `/resume` in the terminal and `session/load` over ACP. |
47 | `sessions.dir` | string | `.mm/sessions` | Directory of the session files, one `<id>.json` each. Relative to the directory `mm` is started from (terminal) or to the session's `cwd` (ACP); an absolute path is used as-is. Must not be empty. |47 | `sessions.dir` | string | `.mm/sessions` | Directory of the session files, one `<id>.json` each. Relative to the directory `mm` is started from (terminal) or to the session's `cwd` (ACP); an absolute path is used as-is. Must not be empty. |
48 48
49 ## Provider defaults49 ## Provider defaults
modified docs/en/reference/slash-commands.md +2 -2
@@ -10,8 +10,8 @@
1010 | `/abort` | Prints `⚠️ No generation in progress.` | Cancels the generation, prints `🛑 Aborting...`; the commands already run stay in the history. |
1111 | `/compact` | Compresses the history now, regardless of the threshold; prints the `🗜️` report, or `🗜️ nothing to compact` when no turn is older than `context.keepLastTurns`. | Sent to the model as a new message (see below). |
1212 | `/new` | Clears the history back to the system prompt, resets the loop detector and forgets the server's last token count; prints `🆕 new session: N message(s) forgotten` (N counts every message except the system prompt). | Treated like any other text: cancels the generation, keeps its partial history, then runs as `/new` on that history. |
13-| `/sessions` | Lists the project's stored sessions, newest first: id, last update, question count, first question; `*` marks the current one. With `sessions.enabled: false`: `⚠️ sessions are off (set sessions.enabled: true in the YAML)`. | Sent to the model as a new message. |
14-| `/resume [id]` | Continues a stored session: the most recently updated one other than the current, skipping empty ones, or the named id. Prints `↩️ resumed <id> — N message(s), started with "<first question>"`; resets the loop detector and the server's token count; later turns are saved to that session. Refused with a `⚠️` reason when sessions are off, when there is no earlier session, when the id is unknown or is the current session. | Sent to the model as a new message. |
13+| `/sessions` | Lists the project's stored sessions, newest first: id, last update, question count, first question, then ` · ⚠️ N error(s)` when the session recorded errors; `*` marks the current one. With `sessions.enabled: false`: `⚠️ sessions are off (set sessions.enabled: true in the YAML)`. | Sent to the model as a new message. |
14+| `/resume [id]` | Continues a stored session: the most recently updated one other than the current, skipping empty ones, or the named id. Prints `↩️ resumed <id> — N message(s), started with "<first question>"` (plus ` · ⚠️ N error(s)` when the session recorded errors); resets the loop detector and the server's token count; later turns are saved to that session. Refused with a `⚠️` reason when sessions are off, when there is no earlier session, when the id is unknown or is the current session. | Sent to the model as a new message. |
1515 | `/help` | Prints the commands of this front end, one per line with their description, aligned. | Sent to the model as a new message. |
1616 | `/copy [n\|all]` | Copies the last answer (`n`: the n-th from the end) to the clipboard through `pbcopy`/`wl-copy`/`xclip`/`xsel` or, failing that, OSC 52 when stdout is a terminal, and prints `📋 copied N chars (<road>)` or `⚠️ …`. `/copy all` is window-mode only: here it says so. | Sent to the model as a new message. |
1717
@@ -10,8 +10,8 @@
10 | `/abort` | Prints `⚠️ No generation in progress.` | Cancels the generation, prints `🛑 Aborting...`; the commands already run stay in the history. |10 | `/abort` | Prints `⚠️ No generation in progress.` | Cancels the generation, prints `🛑 Aborting...`; the commands already run stay in the history. |
11 | `/compact` | Compresses the history now, regardless of the threshold; prints the `🗜️` report, or `🗜️ nothing to compact` when no turn is older than `context.keepLastTurns`. | Sent to the model as a new message (see below). |11 | `/compact` | Compresses the history now, regardless of the threshold; prints the `🗜️` report, or `🗜️ nothing to compact` when no turn is older than `context.keepLastTurns`. | Sent to the model as a new message (see below). |
12 | `/new` | Clears the history back to the system prompt, resets the loop detector and forgets the server's last token count; prints `🆕 new session: N message(s) forgotten` (N counts every message except the system prompt). | Treated like any other text: cancels the generation, keeps its partial history, then runs as `/new` on that history. |12 | `/new` | Clears the history back to the system prompt, resets the loop detector and forgets the server's last token count; prints `🆕 new session: N message(s) forgotten` (N counts every message except the system prompt). | Treated like any other text: cancels the generation, keeps its partial history, then runs as `/new` on that history. |
13-| `/sessions` | Lists the project's stored sessions, newest first: id, last update, question count, first question; `*` marks the current one. With `sessions.enabled: false`: `⚠️ sessions are off (set sessions.enabled: true in the YAML)`. | Sent to the model as a new message. |13+| `/sessions` | Lists the project's stored sessions, newest first: id, last update, question count, first question, then ` · ⚠️ N error(s)` when the session recorded errors; `*` marks the current one. With `sessions.enabled: false`: `⚠️ sessions are off (set sessions.enabled: true in the YAML)`. | Sent to the model as a new message. |
14-| `/resume [id]` | Continues a stored session: the most recently updated one other than the current, skipping empty ones, or the named id. Prints `↩️ resumed <id> — N message(s), started with "<first question>"`; resets the loop detector and the server's token count; later turns are saved to that session. Refused with a `⚠️` reason when sessions are off, when there is no earlier session, when the id is unknown or is the current session. | Sent to the model as a new message. |14+| `/resume [id]` | Continues a stored session: the most recently updated one other than the current, skipping empty ones, or the named id. Prints `↩️ resumed <id> — N message(s), started with "<first question>"` (plus ` · ⚠️ N error(s)` when the session recorded errors); resets the loop detector and the server's token count; later turns are saved to that session. Refused with a `⚠️` reason when sessions are off, when there is no earlier session, when the id is unknown or is the current session. | Sent to the model as a new message. |
15 | `/help` | Prints the commands of this front end, one per line with their description, aligned. | Sent to the model as a new message. |15 | `/help` | Prints the commands of this front end, one per line with their description, aligned. | Sent to the model as a new message. |
16 | `/copy [n\|all]` | Copies the last answer (`n`: the n-th from the end) to the clipboard through `pbcopy`/`wl-copy`/`xclip`/`xsel` or, failing that, OSC 52 when stdout is a terminal, and prints `📋 copied N chars (<road>)` or `⚠️ …`. `/copy all` is window-mode only: here it says so. | Sent to the model as a new message. |16 | `/copy [n\|all]` | Copies the last answer (`n`: the n-th from the end) to the clipboard through `pbcopy`/`wl-copy`/`xclip`/`xsel` or, failing that, OSC 52 when stdout is a terminal, and prints `📋 copied N chars (<road>)` or `⚠️ …`. `/copy all` is window-mode only: here it says so. | Sent to the model as a new message. |
17 17
modified docs/fr/explanation/persistent-sessions.md +9 -0
@@ -28,6 +28,14 @@ Une session rechargée ou reprise continue sous le prompt système actuel, pas c
2828
2929 Dans le terminal, `/new` laisse simplement l'ancien fichier tel quel et la question suivante en ouvre un nouveau. En ACP, l'éditeur possède l'identifiant de session et continue de l'utiliser : le fichier de cet identifiant doit devenir la session neuve, et la conversation écoulée est d'abord déplacée sous un nouvel identifiant. Dans les deux cas rien n'est perdu sur disque, et un rechargement ultérieur du fil rend la conversation courante, pas celle que `/new` a fermée.
3030
31+## Pourquoi les erreurs sont enregistrées à côté de l'historique, pas dedans
32+
33+Un tour peut échouer — serveur arrêté, modèle non téléchargé, chien de garde qui renonce, résumé de compression revenu vide — et la façade le dit en une ligne rouge. Cette ligne était la seule trace : partie avec le défilement, absente du fichier, parce que le fichier contient ce que voit le modèle et qu'un tour raté ne lui laisse rien à voir (la question sans réponse est retirée). Depuis le 18 septembre 2026, le fichier de session porte aussi un tableau `errors` : un enregistrement par ligne affichée, avec l'heure, le nombre de tours, la première ligne de la question en cours, la ligne telle qu'affichée, et l'erreur brute quand elle en dit plus. Trois sortes sont enregistrées : `generation` (une réponse échouée), `compaction` (un résumé échoué, automatique ou `/compact`) et `resume` (un `/resume` qui n'a pas pu charger sa session). Un tour annulé n'est pas une erreur et n'est pas enregistré.
34+
35+Le tableau est à côté de `messages`, pas dedans, à dessein. Le modèle ne le lit jamais : une erreur concerne l'exécution, pas le projet, et la lui renvoyer lui ferait expliquer son infrastructure au lieu de faire sa tâche. `/resume` et `session/load` ne le rejouent jamais : tous deux ne lisent que `messages`, donc une vieille session se charge exactement comme avant et une nouvelle avec des erreurs se charge comme si le champ n'existait pas. Et la compression n'y touche pas : `/compact` réécrit `messages`, la trace reste entière. L'enregistrement suit sa conversation : en ACP, `/new` le déplace dans le fichier archivé avec l'historique auquel il appartient.
36+
37+À quoi cela sert : demander après coup, le fichier en main, pourquoi une exécution a mal tourné — `jq .errors <fichier>` pour un humain, ou le fichier confié à un agent de code à qui l'on demande d'expliquer les échecs et de proposer des changements de code ou de YAML (un `watchdogTimeout` plus long, un autre modèle, un `maxOutput` plus petit). La liste `/sessions` et le compte rendu de `/resume` affichent ` · ⚠️ N error(s)` pour qu'une session à examiner se remarque. La trace est plafonnée à 200 enregistrements, les plus anciens sortent d'abord, pour qu'une exécution qui échoue à chaque tour ne fasse pas grossir son fichier sans fin.
38+
3139 ## Alternatives écartées
3240
3341 - **Reprendre automatiquement la dernière session au lancement.** Écartée pour la raison du contexte périmé, et parce que docker agent ne le fait pas non plus.
@@ -35,6 +43,7 @@ Dans le terminal, `/new` laisse simplement l'ancien fichier tel quel et la quest
3543 - **SQLite, comme docker agent.** Écarté pour un agent minimal : une dépendance de plus, des fichiers opaques, et une base par utilisateur là où mini-me veut des fichiers par projet.
3644 - **`session/resume` (instable) plutôt que `session/load`.** Écarté : hors de la spécification stable, et dépendant d'un éditeur qui aurait gardé le fil.
3745 - **Ne rejouer que les messages texte.** Écarté par l'utilisateur : les appels d'outils sont la partie d'une session de code qui vaut d'être revue.
46+- **Enregistrer les erreurs comme des messages de l'historique.** Écarté : le modèle les lirait, `session/load` devrait les rejouer sous une forme ou une autre, et un vieux fichier se chargerait autrement qu'un nouveau. Un champ séparé ne coûte rien au chargement et ne change rien pour le modèle.
3847
3948 ## Liens avec le reste
4049
@@ -28,6 +28,14 @@ Une session rechargée ou reprise continue sous le prompt système actuel, pas c
28 28
29 Dans le terminal, `/new` laisse simplement l'ancien fichier tel quel et la question suivante en ouvre un nouveau. En ACP, l'éditeur possède l'identifiant de session et continue de l'utiliser : le fichier de cet identifiant doit devenir la session neuve, et la conversation écoulée est d'abord déplacée sous un nouvel identifiant. Dans les deux cas rien n'est perdu sur disque, et un rechargement ultérieur du fil rend la conversation courante, pas celle que `/new` a fermée.29 Dans le terminal, `/new` laisse simplement l'ancien fichier tel quel et la question suivante en ouvre un nouveau. En ACP, l'éditeur possède l'identifiant de session et continue de l'utiliser : le fichier de cet identifiant doit devenir la session neuve, et la conversation écoulée est d'abord déplacée sous un nouvel identifiant. Dans les deux cas rien n'est perdu sur disque, et un rechargement ultérieur du fil rend la conversation courante, pas celle que `/new` a fermée.
30 30
31+## Pourquoi les erreurs sont enregistrées à côté de l'historique, pas dedans
32+
33+Un tour peut échouer — serveur arrêté, modèle non téléchargé, chien de garde qui renonce, résumé de compression revenu vide — et la façade le dit en une ligne rouge. Cette ligne était la seule trace : partie avec le défilement, absente du fichier, parce que le fichier contient ce que voit le modèle et qu'un tour raté ne lui laisse rien à voir (la question sans réponse est retirée). Depuis le 18 septembre 2026, le fichier de session porte aussi un tableau `errors` : un enregistrement par ligne affichée, avec l'heure, le nombre de tours, la première ligne de la question en cours, la ligne telle qu'affichée, et l'erreur brute quand elle en dit plus. Trois sortes sont enregistrées : `generation` (une réponse échouée), `compaction` (un résumé échoué, automatique ou `/compact`) et `resume` (un `/resume` qui n'a pas pu charger sa session). Un tour annulé n'est pas une erreur et n'est pas enregistré.
34+
35+Le tableau est à côté de `messages`, pas dedans, à dessein. Le modèle ne le lit jamais : une erreur concerne l'exécution, pas le projet, et la lui renvoyer lui ferait expliquer son infrastructure au lieu de faire sa tâche. `/resume` et `session/load` ne le rejouent jamais : tous deux ne lisent que `messages`, donc une vieille session se charge exactement comme avant et une nouvelle avec des erreurs se charge comme si le champ n'existait pas. Et la compression n'y touche pas : `/compact` réécrit `messages`, la trace reste entière. L'enregistrement suit sa conversation : en ACP, `/new` le déplace dans le fichier archivé avec l'historique auquel il appartient.
36+
37+À quoi cela sert : demander après coup, le fichier en main, pourquoi une exécution a mal tourné — `jq .errors <fichier>` pour un humain, ou le fichier confié à un agent de code à qui l'on demande d'expliquer les échecs et de proposer des changements de code ou de YAML (un `watchdogTimeout` plus long, un autre modèle, un `maxOutput` plus petit). La liste `/sessions` et le compte rendu de `/resume` affichent ` · ⚠️ N error(s)` pour qu'une session à examiner se remarque. La trace est plafonnée à 200 enregistrements, les plus anciens sortent d'abord, pour qu'une exécution qui échoue à chaque tour ne fasse pas grossir son fichier sans fin.
38+
31 ## Alternatives écartées39 ## Alternatives écartées
32 40
33 - **Reprendre automatiquement la dernière session au lancement.** Écartée pour la raison du contexte périmé, et parce que docker agent ne le fait pas non plus.41 - **Reprendre automatiquement la dernière session au lancement.** Écartée pour la raison du contexte périmé, et parce que docker agent ne le fait pas non plus.
@@ -35,6 +43,7 @@ Dans le terminal, `/new` laisse simplement l'ancien fichier tel quel et la quest
35 - **SQLite, comme docker agent.** Écarté pour un agent minimal : une dépendance de plus, des fichiers opaques, et une base par utilisateur là où mini-me veut des fichiers par projet.43 - **SQLite, comme docker agent.** Écarté pour un agent minimal : une dépendance de plus, des fichiers opaques, et une base par utilisateur là où mini-me veut des fichiers par projet.
36 - **`session/resume` (instable) plutôt que `session/load`.** Écarté : hors de la spécification stable, et dépendant d'un éditeur qui aurait gardé le fil.44 - **`session/resume` (instable) plutôt que `session/load`.** Écarté : hors de la spécification stable, et dépendant d'un éditeur qui aurait gardé le fil.
37 - **Ne rejouer que les messages texte.** Écarté par l'utilisateur : les appels d'outils sont la partie d'une session de code qui vaut d'être revue.45 - **Ne rejouer que les messages texte.** Écarté par l'utilisateur : les appels d'outils sont la partie d'une session de code qui vaut d'être revue.
46+- **Enregistrer les erreurs comme des messages de l'historique.** Écarté : le modèle les lirait, `session/load` devrait les rejouer sous une forme ou une autre, et un vieux fichier se chargerait autrement qu'un nouveau. Un champ séparé ne coûte rien au chargement et ne change rien pour le modèle.
38 47
39 ## Liens avec le reste48 ## Liens avec le reste
40 49
modified docs/fr/how-to/keep-sessions.md +36 -1
@@ -43,6 +43,40 @@ Ce guide montre comment faire enregistrer par `mm` chaque conversation d'un proj
4343
4444 Un `/resume` seul prend la conversation la plus récemment mise à jour autre que la courante, en ignorant les sessions vides ; `/resume <id>` en désigne une. À partir de là, le modèle voit l'historique repris — sous le prompt système actuel — et chaque nouveau tour est enregistré dans le fichier de cette session. Rien n'est rejoué à l'écran : la ligne dit ce qui est revenu.
4545
46+## Comprendre pourquoi une exécution a échoué
47+
48+1. Repérez la session dans la liste : une session qui a affiché au moins une erreur termine sa ligne par ` · ⚠️ N error(s)`. La même queue apparaît dans le compte rendu `↩️ resumed …`.
49+
50+ ```text
51+ > /sessions
52+ 2 session(s) in /work/project/.mm/sessions — /resume <id> to continue one, /resume for the latest
53+ * 20260918-141321-db582a3d 2026-09-18 16:13 1 turn(s) say just: ok
54+ 20260918-141225-02790c82 2026-09-18 16:12 0 turn(s) · ⚠️ 1 error(s)
55+ ```
56+
57+2. Lisez le tableau `errors` de son fichier. Chaque enregistrement dit quand, à quel tour, sur quelle question, ce que l'écran a montré (`message`) et, quand l'erreur brute en dit plus, ce qu'elle était (`detail`) ; `where` vaut `generation`, `compaction` ou `resume` :
58+
59+ ```bash
60+ jq .errors .mm/sessions/20260918-141225-02790c82.json
61+ ```
62+
63+ ```json
64+ [
65+ {
66+ "at": "2026-09-18T14:12:25Z",
67+ "turn": 1,
68+ "where": "generation",
69+ "message": "dmr: \"no/such-model:none\" not found at http://localhost:12434/engines/v1 — docker model pull no/such-model:none",
70+ "detail": "failed to create completion: POST \"http://localhost:12434/engines/v1/chat/completions\": 404 Not Found",
71+ "question": "hello"
72+ }
73+ ]
74+ ```
75+
76+3. Pour faire analyser l'échec, confiez le fichier à un agent de code (Claude Code, ou `mm` lui-même dans un autre projet) et demandez ce qui s'est passé et quoi changer — dans le code, ou dans `agent.yaml` (`watchdogTimeout`, `maxOutput`, `model`, `context.*`). L'historique du même fichier donne le contexte qu'avait le modèle ; le tableau `errors` donne ce qu'il n'a jamais vu.
77+
78+ Une session dont le seul contenu est une erreur n'a aucun tour : un `/resume` seul la saute, `/resume <id>` l'atteint.
79+
4680 ## Depuis un éditeur (ACP)
4781
4882 1. Gardez `sessions.enabled: true` dans le fichier désigné par `AGENT_CONFIG`. Chaque fil ouvert par l'éditeur est une session, enregistrée sous `<projet>/.mm/sessions/<id>.json` dès sa création.
@@ -55,7 +89,8 @@ Ce guide montre comment faire enregistrer par `mm` chaque conversation d'un proj
5589
5690 - **Un seul dépôt pour tous les projets.** Donnez un chemin absolu à `dir`. Les sessions de différents projets partagent alors un répertoire ; chaque fichier enregistre son `cwd`.
5791 - **Un projet en lecture seule.** La bannière ou le journal `[acp]` indique `not saved` avec la raison ; la conversation continue sans être enregistrée.
58-- **Compression.** Un `/compact` ou une compression automatique réécrit le fichier avec l'historique raccourci ; une session reprise ou rechargée repart du résumé, comme la session en direct.
92+- **Compression.** Un `/compact` ou une compression automatique réécrit le fichier avec l'historique raccourci ; une session reprise ou rechargée repart du résumé, comme la session en direct. Le tableau `errors` reste tel quel.
93+- **Erreurs en ACP.** L'éditeur montre l'échec à sa manière ; l'enregistrement va quand même dans le fichier du fil, et `/new` le déplace dans la conversation archivée avec l'historique auquel il appartient.
5994 - **Repartir de zéro volontairement.** Rien à faire : chaque lancement est une nouvelle session. Les anciennes ne reviennent que sur `/resume` ou sur un rechargement depuis l'éditeur.
6095
6196 ## Voir aussi
@@ -43,6 +43,40 @@ Ce guide montre comment faire enregistrer par `mm` chaque conversation d'un proj
43 43
44 Un `/resume` seul prend la conversation la plus récemment mise à jour autre que la courante, en ignorant les sessions vides ; `/resume <id>` en désigne une. À partir de là, le modèle voit l'historique repris — sous le prompt système actuel — et chaque nouveau tour est enregistré dans le fichier de cette session. Rien n'est rejoué à l'écran : la ligne dit ce qui est revenu.44 Un `/resume` seul prend la conversation la plus récemment mise à jour autre que la courante, en ignorant les sessions vides ; `/resume <id>` en désigne une. À partir de là, le modèle voit l'historique repris — sous le prompt système actuel — et chaque nouveau tour est enregistré dans le fichier de cette session. Rien n'est rejoué à l'écran : la ligne dit ce qui est revenu.
45 45
46+## Comprendre pourquoi une exécution a échoué
47+
48+1. Repérez la session dans la liste : une session qui a affiché au moins une erreur termine sa ligne par ` · ⚠️ N error(s)`. La même queue apparaît dans le compte rendu `↩️ resumed …`.
49+
50+ ```text
51+ > /sessions
52+ 2 session(s) in /work/project/.mm/sessions — /resume <id> to continue one, /resume for the latest
53+ * 20260918-141321-db582a3d 2026-09-18 16:13 1 turn(s) say just: ok
54+ 20260918-141225-02790c82 2026-09-18 16:12 0 turn(s) · ⚠️ 1 error(s)
55+ ```
56+
57+2. Lisez le tableau `errors` de son fichier. Chaque enregistrement dit quand, à quel tour, sur quelle question, ce que l'écran a montré (`message`) et, quand l'erreur brute en dit plus, ce qu'elle était (`detail`) ; `where` vaut `generation`, `compaction` ou `resume` :
58+
59+ ```bash
60+ jq .errors .mm/sessions/20260918-141225-02790c82.json
61+ ```
62+
63+ ```json
64+ [
65+ {
66+ "at": "2026-09-18T14:12:25Z",
67+ "turn": 1,
68+ "where": "generation",
69+ "message": "dmr: \"no/such-model:none\" not found at http://localhost:12434/engines/v1 — docker model pull no/such-model:none",
70+ "detail": "failed to create completion: POST \"http://localhost:12434/engines/v1/chat/completions\": 404 Not Found",
71+ "question": "hello"
72+ }
73+ ]
74+ ```
75+
76+3. Pour faire analyser l'échec, confiez le fichier à un agent de code (Claude Code, ou `mm` lui-même dans un autre projet) et demandez ce qui s'est passé et quoi changer — dans le code, ou dans `agent.yaml` (`watchdogTimeout`, `maxOutput`, `model`, `context.*`). L'historique du même fichier donne le contexte qu'avait le modèle ; le tableau `errors` donne ce qu'il n'a jamais vu.
77+
78+ Une session dont le seul contenu est une erreur n'a aucun tour : un `/resume` seul la saute, `/resume <id>` l'atteint.
79+
46 ## Depuis un éditeur (ACP)80 ## Depuis un éditeur (ACP)
47 81
48 1. Gardez `sessions.enabled: true` dans le fichier désigné par `AGENT_CONFIG`. Chaque fil ouvert par l'éditeur est une session, enregistrée sous `<projet>/.mm/sessions/<id>.json` dès sa création.82 1. Gardez `sessions.enabled: true` dans le fichier désigné par `AGENT_CONFIG`. Chaque fil ouvert par l'éditeur est une session, enregistrée sous `<projet>/.mm/sessions/<id>.json` dès sa création.
@@ -55,7 +89,8 @@ Ce guide montre comment faire enregistrer par `mm` chaque conversation d'un proj
55 89
56 - **Un seul dépôt pour tous les projets.** Donnez un chemin absolu à `dir`. Les sessions de différents projets partagent alors un répertoire ; chaque fichier enregistre son `cwd`.90 - **Un seul dépôt pour tous les projets.** Donnez un chemin absolu à `dir`. Les sessions de différents projets partagent alors un répertoire ; chaque fichier enregistre son `cwd`.
57 - **Un projet en lecture seule.** La bannière ou le journal `[acp]` indique `not saved` avec la raison ; la conversation continue sans être enregistrée.91 - **Un projet en lecture seule.** La bannière ou le journal `[acp]` indique `not saved` avec la raison ; la conversation continue sans être enregistrée.
58-- **Compression.** Un `/compact` ou une compression automatique réécrit le fichier avec l'historique raccourci ; une session reprise ou rechargée repart du résumé, comme la session en direct.92+- **Compression.** Un `/compact` ou une compression automatique réécrit le fichier avec l'historique raccourci ; une session reprise ou rechargée repart du résumé, comme la session en direct. Le tableau `errors` reste tel quel.
93+- **Erreurs en ACP.** L'éditeur montre l'échec à sa manière ; l'enregistrement va quand même dans le fichier du fil, et `/new` le déplace dans la conversation archivée avec l'historique auquel il appartient.
59 - **Repartir de zéro volontairement.** Rien à faire : chaque lancement est une nouvelle session. Les anciennes ne reviennent que sur `/resume` ou sur un rechargement depuis l'éditeur.94 - **Repartir de zéro volontairement.** Rien à faire : chaque lancement est une nouvelle session. Les anciennes ne reviennent que sur `/resume` ou sur un rechargement depuis l'éditeur.
60 95
61 ## Voir aussi96 ## Voir aussi
modified docs/fr/reference/configuration.md +1 -1
@@ -43,7 +43,7 @@
4343
4444 | Clé | Type | Défaut | Description |
4545 |-----|------|--------|-------------|
46-| `sessions.enabled` | bool | `false` | Enregistre chaque conversation dans un fichier, réécrit après chaque tour, `/compact` et `/new`. Active `/sessions` et `/resume` dans le terminal et `session/load` en ACP. |
46+| `sessions.enabled` | bool | `false` | Enregistre chaque conversation dans un fichier, réécrit après chaque tour, `/compact` et `/new`. Le fichier enregistre aussi, hors de l'historique, chaque erreur affichée (tableau `errors` : génération échouée, compression échouée, `/resume` échoué). Active `/sessions` et `/resume` dans le terminal et `session/load` en ACP. |
4747 | `sessions.dir` | chaîne | `.mm/sessions` | Répertoire des fichiers de session, un `<id>.json` chacun. Relatif au répertoire de lancement de `mm` (terminal) ou au `cwd` de la session (ACP) ; un chemin absolu est utilisé tel quel. Ne doit pas être vide. |
4848
4949 ## Valeurs par défaut des fournisseurs
@@ -43,7 +43,7 @@
43 43
44 | Clé | Type | Défaut | Description |44 | Clé | Type | Défaut | Description |
45 |-----|------|--------|-------------|45 |-----|------|--------|-------------|
46-| `sessions.enabled` | bool | `false` | Enregistre chaque conversation dans un fichier, réécrit après chaque tour, `/compact` et `/new`. Active `/sessions` et `/resume` dans le terminal et `session/load` en ACP. |46+| `sessions.enabled` | bool | `false` | Enregistre chaque conversation dans un fichier, réécrit après chaque tour, `/compact` et `/new`. Le fichier enregistre aussi, hors de l'historique, chaque erreur affichée (tableau `errors` : génération échouée, compression échouée, `/resume` échoué). Active `/sessions` et `/resume` dans le terminal et `session/load` en ACP. |
47 | `sessions.dir` | chaîne | `.mm/sessions` | Répertoire des fichiers de session, un `<id>.json` chacun. Relatif au répertoire de lancement de `mm` (terminal) ou au `cwd` de la session (ACP) ; un chemin absolu est utilisé tel quel. Ne doit pas être vide. |47 | `sessions.dir` | chaîne | `.mm/sessions` | Répertoire des fichiers de session, un `<id>.json` chacun. Relatif au répertoire de lancement de `mm` (terminal) ou au `cwd` de la session (ACP) ; un chemin absolu est utilisé tel quel. Ne doit pas être vide. |
48 48
49 ## Valeurs par défaut des fournisseurs49 ## Valeurs par défaut des fournisseurs
modified docs/fr/reference/slash-commands.md +2 -2
@@ -10,8 +10,8 @@
1010 | `/abort` | Affiche `⚠️ No generation in progress.` | Annule la génération, affiche `🛑 Aborting...` ; les commandes déjà exécutées restent dans l'historique. |
1111 | `/compact` | Compresse l'historique immédiatement, sans tenir compte du seuil ; affiche le rapport `🗜️`, ou `🗜️ nothing to compact` quand aucun tour n'est plus ancien que `context.keepLastTurns`. | Envoyée au modèle comme nouveau message (voir ci-dessous). |
1212 | `/new` | Ramène l'historique au seul prompt système, réinitialise le détecteur de boucles et oublie le dernier compte de tokens du serveur ; affiche `🆕 new session: N message(s) forgotten` (N compte tous les messages sauf le prompt système). | Traitée comme n'importe quel texte : annule la génération, conserve son historique partiel, puis s'exécute comme `/new` sur cet historique. |
13-| `/sessions` | Liste les sessions enregistrées du projet, la plus récente d'abord : identifiant, dernière mise à jour, nombre de questions, première question ; `*` marque la session courante. Avec `sessions.enabled: false` : `⚠️ sessions are off (set sessions.enabled: true in the YAML)`. | Envoyée au modèle comme nouveau message. |
14-| `/resume [id]` | Reprend une session enregistrée : la plus récemment mise à jour autre que la courante, en ignorant les vides, ou l'identifiant donné. Affiche `↩️ resumed <id> — N message(s), started with "<première question>"` ; réinitialise le détecteur de boucles et le compte de tokens du serveur ; les tours suivants sont enregistrés dans cette session. Refusée avec une raison `⚠️` quand les sessions sont désactivées, qu'il n'y a pas de session antérieure, que l'identifiant est inconnu ou désigne la session courante. | Envoyée au modèle comme nouveau message. |
13+| `/sessions` | Liste les sessions enregistrées du projet, la plus récente d'abord : identifiant, dernière mise à jour, nombre de questions, première question, puis ` · ⚠️ N error(s)` quand la session a enregistré des erreurs ; `*` marque la session courante. Avec `sessions.enabled: false` : `⚠️ sessions are off (set sessions.enabled: true in the YAML)`. | Envoyée au modèle comme nouveau message. |
14+| `/resume [id]` | Reprend une session enregistrée : la plus récemment mise à jour autre que la courante, en ignorant les vides, ou l'identifiant donné. Affiche `↩️ resumed <id> — N message(s), started with "<première question>"` (suivi de ` · ⚠️ N error(s)` quand la session a enregistré des erreurs) ; réinitialise le détecteur de boucles et le compte de tokens du serveur ; les tours suivants sont enregistrés dans cette session. Refusée avec une raison `⚠️` quand les sessions sont désactivées, qu'il n'y a pas de session antérieure, que l'identifiant est inconnu ou désigne la session courante. | Envoyée au modèle comme nouveau message. |
1515 | `/help` | Affiche les commandes de cette façade, une par ligne avec leur description, alignées. | Envoyée au modèle comme nouveau message. |
1616 | `/copy [n\|all]` | Copie la dernière réponse (`n` : la n-ième depuis la fin) dans le presse-papiers via `pbcopy`/`wl-copy`/`xclip`/`xsel` ou, à défaut, OSC 52 quand stdout est un terminal, et affiche `📋 copied N chars (<chemin>)` ou `⚠️ …`. `/copy all` est réservé au mode fenêtre : ici la commande le dit. | Envoyée au modèle comme nouveau message. |
1717
@@ -10,8 +10,8 @@
10 | `/abort` | Affiche `⚠️ No generation in progress.` | Annule la génération, affiche `🛑 Aborting...` ; les commandes déjà exécutées restent dans l'historique. |10 | `/abort` | Affiche `⚠️ No generation in progress.` | Annule la génération, affiche `🛑 Aborting...` ; les commandes déjà exécutées restent dans l'historique. |
11 | `/compact` | Compresse l'historique immédiatement, sans tenir compte du seuil ; affiche le rapport `🗜️`, ou `🗜️ nothing to compact` quand aucun tour n'est plus ancien que `context.keepLastTurns`. | Envoyée au modèle comme nouveau message (voir ci-dessous). |11 | `/compact` | Compresse l'historique immédiatement, sans tenir compte du seuil ; affiche le rapport `🗜️`, ou `🗜️ nothing to compact` quand aucun tour n'est plus ancien que `context.keepLastTurns`. | Envoyée au modèle comme nouveau message (voir ci-dessous). |
12 | `/new` | Ramène l'historique au seul prompt système, réinitialise le détecteur de boucles et oublie le dernier compte de tokens du serveur ; affiche `🆕 new session: N message(s) forgotten` (N compte tous les messages sauf le prompt système). | Traitée comme n'importe quel texte : annule la génération, conserve son historique partiel, puis s'exécute comme `/new` sur cet historique. |12 | `/new` | Ramène l'historique au seul prompt système, réinitialise le détecteur de boucles et oublie le dernier compte de tokens du serveur ; affiche `🆕 new session: N message(s) forgotten` (N compte tous les messages sauf le prompt système). | Traitée comme n'importe quel texte : annule la génération, conserve son historique partiel, puis s'exécute comme `/new` sur cet historique. |
13-| `/sessions` | Liste les sessions enregistrées du projet, la plus récente d'abord : identifiant, dernière mise à jour, nombre de questions, première question ; `*` marque la session courante. Avec `sessions.enabled: false` : `⚠️ sessions are off (set sessions.enabled: true in the YAML)`. | Envoyée au modèle comme nouveau message. |13+| `/sessions` | Liste les sessions enregistrées du projet, la plus récente d'abord : identifiant, dernière mise à jour, nombre de questions, première question, puis ` · ⚠️ N error(s)` quand la session a enregistré des erreurs ; `*` marque la session courante. Avec `sessions.enabled: false` : `⚠️ sessions are off (set sessions.enabled: true in the YAML)`. | Envoyée au modèle comme nouveau message. |
14-| `/resume [id]` | Reprend une session enregistrée : la plus récemment mise à jour autre que la courante, en ignorant les vides, ou l'identifiant donné. Affiche `↩️ resumed <id> — N message(s), started with "<première question>"` ; réinitialise le détecteur de boucles et le compte de tokens du serveur ; les tours suivants sont enregistrés dans cette session. Refusée avec une raison `⚠️` quand les sessions sont désactivées, qu'il n'y a pas de session antérieure, que l'identifiant est inconnu ou désigne la session courante. | Envoyée au modèle comme nouveau message. |14+| `/resume [id]` | Reprend une session enregistrée : la plus récemment mise à jour autre que la courante, en ignorant les vides, ou l'identifiant donné. Affiche `↩️ resumed <id> — N message(s), started with "<première question>"` (suivi de ` · ⚠️ N error(s)` quand la session a enregistré des erreurs) ; réinitialise le détecteur de boucles et le compte de tokens du serveur ; les tours suivants sont enregistrés dans cette session. Refusée avec une raison `⚠️` quand les sessions sont désactivées, qu'il n'y a pas de session antérieure, que l'identifiant est inconnu ou désigne la session courante. | Envoyée au modèle comme nouveau message. |
15 | `/help` | Affiche les commandes de cette façade, une par ligne avec leur description, alignées. | Envoyée au modèle comme nouveau message. |15 | `/help` | Affiche les commandes de cette façade, une par ligne avec leur description, alignées. | Envoyée au modèle comme nouveau message. |
16 | `/copy [n\|all]` | Copie la dernière réponse (`n` : la n-ième depuis la fin) dans le presse-papiers via `pbcopy`/`wl-copy`/`xclip`/`xsel` ou, à défaut, OSC 52 quand stdout est un terminal, et affiche `📋 copied N chars (<chemin>)` ou `⚠️ …`. `/copy all` est réservé au mode fenêtre : ici la commande le dit. | Envoyée au modèle comme nouveau message. |16 | `/copy [n\|all]` | Copie la dernière réponse (`n` : la n-ième depuis la fin) dans le presse-papiers via `pbcopy`/`wl-copy`/`xclip`/`xsel` ou, à défaut, OSC 52 quand stdout est un terminal, et affiche `📋 copied N chars (<chemin>)` ou `⚠️ …`. `/copy all` est réservé au mode fenêtre : ici la commande le dit. | Envoyée au modèle comme nouveau message. |
17 17
modified internal/acp/acp.go +17 -11
@@ -313,6 +313,7 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
313313 // when the turn failed or was cancelled — the commands that already ran are
314314 // part of the conversation, and forgetting them makes the model re-run or
315315 // invent them on the next turn.
316+ asked := s.messages // question included, for the error record below
316317 s.messages = history.AfterTurn(s.messages, before, resp, full, err)
317318
318319 // The ⚙ recap of the REPL, one line on stderr: the editor already renders
@@ -335,8 +336,11 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
335336 return sdk.PromptResponse{StopReason: sdk.StopReasonCancelled}, nil
336337 case err != nil:
337338 // One line, in the provider's words — the same text the REPL prints
338- // between brackets, delivered as a JSON-RPC error.
339- return sdk.PromptResponse{}, sdk.NewInternalError(map[string]any{"error": f.e.Explain(err)})
339+ // between brackets, delivered as a JSON-RPC error — and recorded in
340+ // the session's file, where the editor's red banner is not.
341+ explained := f.e.Explain(err)
342+ f.fail(p.SessionId, s, store.NewFailure(asked, store.FailGeneration, explained, err))
343+ return sdk.PromptResponse{}, sdk.NewInternalError(map[string]any{"error": explained})
340344 }
341345 return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}, nil
342346 }
@@ -380,12 +384,15 @@ func (f *front) startNewSession(ctx context.Context, sid sdk.SessionId, s *sessi
380384 // turnMu: Genkit is not holding this history.
381385 func (f *front) compactSession(ctx context.Context, sid sdk.SessionId, s *session) sdk.PromptResponse {
382386 cfg := config.Cfg.Context
383- line, compressed := compactHistory(ctx, s, cfg, func(ctx context.Context, request []*ai.Message) (string, error) {
387+ line, outcome := compactHistory(ctx, s, cfg, func(ctx context.Context, request []*ai.Message) (string, error) {
384388 return f.e.Summarize(ctx, request, cfg.SummaryMaxTokens)
385389 }, f.e.Explain)
386- if compressed {
390+ switch outcome {
391+ case compact.Compressed:
387392 f.e.ForgetInputTokens() // the server's measure described the history just replaced
388393 f.persist(sid, s)
394+ case compact.Failed:
395+ f.fail(sid, s, store.NewFailure(s.messages, store.FailCompaction, line, nil))
389396 }
390397 fmt.Fprintf(os.Stderr, "[acp] session %s: /compact — %s\n", sid, line)
391398 _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{
@@ -397,14 +404,13 @@ func (f *front) compactSession(ctx context.Context, sid sdk.SessionId, s *sessio
397404
398405 // compactHistory compresses the session's history in place when there is
399406 // something to compress, and returns the one line to show — the REPL's words,
400-// so the editor and the terminal agree — and whether the history changed.
401-// Kept apart from compactSession, with the summariser and the error explainer
402-// injected, so the three outcomes are testable without an engine or a
403-// connection.
404-func compactHistory(ctx context.Context, s *session, cfg config.ContextConfig, summarize compact.Summarizer, explain func(error) string) (line string, compressed bool) {
405- var outcome compact.Outcome
407+// so the editor and the terminal agree — and how it ended (the history
408+// changed on compact.Compressed alone). Kept apart from compactSession, with
409+// the summariser and the error explainer injected, so the three outcomes are
410+// testable without an engine or a connection.
411+func compactHistory(ctx context.Context, s *session, cfg config.ContextConfig, summarize compact.Summarizer, explain func(error) string) (line string, outcome compact.Outcome) {
406412 s.messages, line, outcome = compact.Apply(ctx, s.messages, cfg, summarize, explain)
407- return line, outcome == compact.Compressed
413+ return line, outcome
408414 }
409415
410416 // resetSession replaces the session's history with a fresh one and returns how
@@ -313,6 +313,7 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
313 // when the turn failed or was cancelled — the commands that already ran are313 // when the turn failed or was cancelled — the commands that already ran are
314 // part of the conversation, and forgetting them makes the model re-run or314 // part of the conversation, and forgetting them makes the model re-run or
315 // invent them on the next turn.315 // invent them on the next turn.
316+ asked := s.messages // question included, for the error record below
316 s.messages = history.AfterTurn(s.messages, before, resp, full, err)317 s.messages = history.AfterTurn(s.messages, before, resp, full, err)
317 318
318 // The ⚙ recap of the REPL, one line on stderr: the editor already renders319 // The ⚙ recap of the REPL, one line on stderr: the editor already renders
@@ -335,8 +336,11 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
335 return sdk.PromptResponse{StopReason: sdk.StopReasonCancelled}, nil336 return sdk.PromptResponse{StopReason: sdk.StopReasonCancelled}, nil
336 case err != nil:337 case err != nil:
337 // One line, in the provider's words — the same text the REPL prints338 // One line, in the provider's words — the same text the REPL prints
338- // between brackets, delivered as a JSON-RPC error.339+ // between brackets, delivered as a JSON-RPC error — and recorded in
339- return sdk.PromptResponse{}, sdk.NewInternalError(map[string]any{"error": f.e.Explain(err)})340+ // the session's file, where the editor's red banner is not.
341+ explained := f.e.Explain(err)
342+ f.fail(p.SessionId, s, store.NewFailure(asked, store.FailGeneration, explained, err))
343+ return sdk.PromptResponse{}, sdk.NewInternalError(map[string]any{"error": explained})
340 }344 }
341 return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}, nil345 return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}, nil
342 }346 }
@@ -380,12 +384,15 @@ func (f *front) startNewSession(ctx context.Context, sid sdk.SessionId, s *sessi
380 // turnMu: Genkit is not holding this history.384 // turnMu: Genkit is not holding this history.
381 func (f *front) compactSession(ctx context.Context, sid sdk.SessionId, s *session) sdk.PromptResponse {385 func (f *front) compactSession(ctx context.Context, sid sdk.SessionId, s *session) sdk.PromptResponse {
382 cfg := config.Cfg.Context386 cfg := config.Cfg.Context
383- line, compressed := compactHistory(ctx, s, cfg, func(ctx context.Context, request []*ai.Message) (string, error) {387+ line, outcome := compactHistory(ctx, s, cfg, func(ctx context.Context, request []*ai.Message) (string, error) {
384 return f.e.Summarize(ctx, request, cfg.SummaryMaxTokens)388 return f.e.Summarize(ctx, request, cfg.SummaryMaxTokens)
385 }, f.e.Explain)389 }, f.e.Explain)
386- if compressed {390+ switch outcome {
391+ case compact.Compressed:
387 f.e.ForgetInputTokens() // the server's measure described the history just replaced392 f.e.ForgetInputTokens() // the server's measure described the history just replaced
388 f.persist(sid, s)393 f.persist(sid, s)
394+ case compact.Failed:
395+ f.fail(sid, s, store.NewFailure(s.messages, store.FailCompaction, line, nil))
389 }396 }
390 fmt.Fprintf(os.Stderr, "[acp] session %s: /compact — %s\n", sid, line)397 fmt.Fprintf(os.Stderr, "[acp] session %s: /compact — %s\n", sid, line)
391 _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{398 _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{
@@ -397,14 +404,13 @@ func (f *front) compactSession(ctx context.Context, sid sdk.SessionId, s *sessio
397 404
398 // compactHistory compresses the session's history in place when there is405 // compactHistory compresses the session's history in place when there is
399 // something to compress, and returns the one line to show — the REPL's words,406 // something to compress, and returns the one line to show — the REPL's words,
400-// so the editor and the terminal agree — and whether the history changed.407+// so the editor and the terminal agree — and how it ended (the history
401-// Kept apart from compactSession, with the summariser and the error explainer408+// changed on compact.Compressed alone). Kept apart from compactSession, with
402-// injected, so the three outcomes are testable without an engine or a409+// the summariser and the error explainer injected, so the three outcomes are
403-// connection.410+// testable without an engine or a connection.
404-func compactHistory(ctx context.Context, s *session, cfg config.ContextConfig, summarize compact.Summarizer, explain func(error) string) (line string, compressed bool) {411+func compactHistory(ctx context.Context, s *session, cfg config.ContextConfig, summarize compact.Summarizer, explain func(error) string) (line string, outcome compact.Outcome) {
405- var outcome compact.Outcome
406 s.messages, line, outcome = compact.Apply(ctx, s.messages, cfg, summarize, explain)412 s.messages, line, outcome = compact.Apply(ctx, s.messages, cfg, summarize, explain)
407- return line, outcome == compact.Compressed413+ return line, outcome
408 }414 }
409 415
410 // resetSession replaces the session's history with a fresh one and returns how416 // resetSession replaces the session's history with a fresh one and returns how
modified internal/acp/acp_test.go +10 -9
@@ -11,6 +11,7 @@ import (
1111 "time"
1212
1313 "mm/internal/command"
14+ "mm/internal/compact"
1415 "mm/internal/config"
1516 history "mm/internal/session"
1617
@@ -251,9 +252,9 @@ func TestCompactHistoryOutcomes(t *testing.T) {
251252 t.Run("compresses the turns older than the kept ones", func(t *testing.T) {
252253 s := sessionWithTurns("a", "b", "c")
253254 before := len(s.messages)
254- line, compressed := compactHistory(context.Background(), s, cfg, summary, explain)
255- if !compressed {
256- t.Fatalf("compressed = false, line %q", line)
255+ line, outcome := compactHistory(context.Background(), s, cfg, summary, explain)
256+ if outcome != compact.Compressed {
257+ t.Fatalf("outcome %v, line %q", outcome, line)
257258 }
258259 if !strings.HasPrefix(line, "🗜️ compressed 8 messages → 1 summary + 4 kept") {
259260 t.Errorf("report %q does not open with the REPL's words and the right counts", line)
@@ -269,9 +270,9 @@ func TestCompactHistoryOutcomes(t *testing.T) {
269270 t.Run("nothing to compact is said, not swallowed", func(t *testing.T) {
270271 s := sessionWithTurns("a")
271272 before := len(s.messages)
272- line, compressed := compactHistory(context.Background(), s, cfg, summary, explain)
273- if compressed || len(s.messages) != before {
274- t.Errorf("compressed = %v, %d messages (was %d): a single kept turn must not move", compressed, len(s.messages), before)
273+ line, outcome := compactHistory(context.Background(), s, cfg, summary, explain)
274+ if outcome != compact.Nothing || len(s.messages) != before {
275+ t.Errorf("outcome %v, %d messages (was %d): a single kept turn must not move", outcome, len(s.messages), before)
275276 }
276277 if line != "🗜️ nothing to compact: 5 message(s), no turn older than the last 1" {
277278 t.Errorf("line %q", line)
@@ -282,9 +283,9 @@ func TestCompactHistoryOutcomes(t *testing.T) {
282283 s := sessionWithTurns("a", "b", "c")
283284 before := len(s.messages)
284285 failing := func(context.Context, []*ai.Message) (string, error) { return "", errors.New("server down") }
285- line, compressed := compactHistory(context.Background(), s, cfg, failing, explain)
286- if compressed || len(s.messages) != before {
287- t.Errorf("compressed = %v, %d messages (was %d): a failure must leave the history alone", compressed, len(s.messages), before)
286+ line, outcome := compactHistory(context.Background(), s, cfg, failing, explain)
287+ if outcome != compact.Failed || len(s.messages) != before {
288+ t.Errorf("outcome %v, %d messages (was %d): a failure must leave the history alone", outcome, len(s.messages), before)
288289 }
289290 if line != "[compact: failed, history kept: explained: server down]" {
290291 t.Errorf("line %q", line)
@@ -11,6 +11,7 @@ import (
11 "time"11 "time"
12 12
13 "mm/internal/command"13 "mm/internal/command"
14+ "mm/internal/compact"
14 "mm/internal/config"15 "mm/internal/config"
15 history "mm/internal/session"16 history "mm/internal/session"
16 17
@@ -251,9 +252,9 @@ func TestCompactHistoryOutcomes(t *testing.T) {
251 t.Run("compresses the turns older than the kept ones", func(t *testing.T) {252 t.Run("compresses the turns older than the kept ones", func(t *testing.T) {
252 s := sessionWithTurns("a", "b", "c")253 s := sessionWithTurns("a", "b", "c")
253 before := len(s.messages)254 before := len(s.messages)
254- line, compressed := compactHistory(context.Background(), s, cfg, summary, explain)255+ line, outcome := compactHistory(context.Background(), s, cfg, summary, explain)
255- if !compressed {256+ if outcome != compact.Compressed {
256- t.Fatalf("compressed = false, line %q", line)257+ t.Fatalf("outcome %v, line %q", outcome, line)
257 }258 }
258 if !strings.HasPrefix(line, "🗜️ compressed 8 messages → 1 summary + 4 kept") {259 if !strings.HasPrefix(line, "🗜️ compressed 8 messages → 1 summary + 4 kept") {
259 t.Errorf("report %q does not open with the REPL's words and the right counts", line)260 t.Errorf("report %q does not open with the REPL's words and the right counts", line)
@@ -269,9 +270,9 @@ func TestCompactHistoryOutcomes(t *testing.T) {
269 t.Run("nothing to compact is said, not swallowed", func(t *testing.T) {270 t.Run("nothing to compact is said, not swallowed", func(t *testing.T) {
270 s := sessionWithTurns("a")271 s := sessionWithTurns("a")
271 before := len(s.messages)272 before := len(s.messages)
272- line, compressed := compactHistory(context.Background(), s, cfg, summary, explain)273+ line, outcome := compactHistory(context.Background(), s, cfg, summary, explain)
273- if compressed || len(s.messages) != before {274+ if outcome != compact.Nothing || len(s.messages) != before {
274- t.Errorf("compressed = %v, %d messages (was %d): a single kept turn must not move", compressed, len(s.messages), before)275+ t.Errorf("outcome %v, %d messages (was %d): a single kept turn must not move", outcome, len(s.messages), before)
275 }276 }
276 if line != "🗜️ nothing to compact: 5 message(s), no turn older than the last 1" {277 if line != "🗜️ nothing to compact: 5 message(s), no turn older than the last 1" {
277 t.Errorf("line %q", line)278 t.Errorf("line %q", line)
@@ -282,9 +283,9 @@ func TestCompactHistoryOutcomes(t *testing.T) {
282 s := sessionWithTurns("a", "b", "c")283 s := sessionWithTurns("a", "b", "c")
283 before := len(s.messages)284 before := len(s.messages)
284 failing := func(context.Context, []*ai.Message) (string, error) { return "", errors.New("server down") }285 failing := func(context.Context, []*ai.Message) (string, error) { return "", errors.New("server down") }
285- line, compressed := compactHistory(context.Background(), s, cfg, failing, explain)286+ line, outcome := compactHistory(context.Background(), s, cfg, failing, explain)
286- if compressed || len(s.messages) != before {287+ if outcome != compact.Failed || len(s.messages) != before {
287- t.Errorf("compressed = %v, %d messages (was %d): a failure must leave the history alone", compressed, len(s.messages), before)288+ t.Errorf("outcome %v, %d messages (was %d): a failure must leave the history alone", outcome, len(s.messages), before)
288 }289 }
289 if line != "[compact: failed, history kept: explained: server down]" {290 if line != "[compact: failed, history kept: explained: server down]" {
290 t.Errorf("line %q", line)291 t.Errorf("line %q", line)
modified internal/acp/persist.go +12 -1
@@ -94,6 +94,16 @@ func (f *front) persist(sid sdk.SessionId, s *session) {
9494 }
9595 }
9696
97+// fail records an error the editor was just shown — see store.NewFailure —
98+// and rewrites the session's file. Nothing happens without persistence.
99+func (f *front) fail(sid sdk.SessionId, s *session, rec store.Failure) {
100+ if s.record == nil {
101+ return
102+ }
103+ s.record.Fail(rec)
104+ f.persist(sid, s)
105+}
106+
97107 // archive is /new's side of persistence. The editor keeps its session id, so
98108 // that id's file must become the new, empty session — but the old
99109 // conversation stays on disk under a fresh id, like a terminal session left
@@ -102,11 +112,12 @@ func (f *front) archive(sid sdk.SessionId, s *session) {
102112 if s.record == nil || history.Forgotten(s.messages) == 0 {
103113 return
104114 }
105- old := &store.Session{ID: store.NewID(), Cwd: s.cwd, CreatedAt: s.record.CreatedAt, Messages: s.messages}
115+ old := &store.Session{ID: store.NewID(), Cwd: s.cwd, CreatedAt: s.record.CreatedAt, Messages: s.messages, Errors: s.record.Errors}
106116 if err := s.st.Save(old); err != nil {
107117 fmt.Fprintf(os.Stderr, "[acp] session %s: previous conversation not archived: %v\n", sid, err)
108118 return
109119 }
110120 s.record.CreatedAt = time.Now().UTC()
121+ s.record.Errors = nil // they belong to the conversation just archived
111122 fmt.Fprintf(os.Stderr, "[acp] session %s: previous conversation kept as %s\n", sid, old.ID)
112123 }
@@ -94,6 +94,16 @@ func (f *front) persist(sid sdk.SessionId, s *session) {
94 }94 }
95 }95 }
96 96
97+// fail records an error the editor was just shown — see store.NewFailure —
98+// and rewrites the session's file. Nothing happens without persistence.
99+func (f *front) fail(sid sdk.SessionId, s *session, rec store.Failure) {
100+ if s.record == nil {
101+ return
102+ }
103+ s.record.Fail(rec)
104+ f.persist(sid, s)
105+}
106+
97 // archive is /new's side of persistence. The editor keeps its session id, so107 // archive is /new's side of persistence. The editor keeps its session id, so
98 // that id's file must become the new, empty session — but the old108 // that id's file must become the new, empty session — but the old
99 // conversation stays on disk under a fresh id, like a terminal session left109 // conversation stays on disk under a fresh id, like a terminal session left
@@ -102,11 +112,12 @@ func (f *front) archive(sid sdk.SessionId, s *session) {
102 if s.record == nil || history.Forgotten(s.messages) == 0 {112 if s.record == nil || history.Forgotten(s.messages) == 0 {
103 return113 return
104 }114 }
105- old := &store.Session{ID: store.NewID(), Cwd: s.cwd, CreatedAt: s.record.CreatedAt, Messages: s.messages}115+ old := &store.Session{ID: store.NewID(), Cwd: s.cwd, CreatedAt: s.record.CreatedAt, Messages: s.messages, Errors: s.record.Errors}
106 if err := s.st.Save(old); err != nil {116 if err := s.st.Save(old); err != nil {
107 fmt.Fprintf(os.Stderr, "[acp] session %s: previous conversation not archived: %v\n", sid, err)117 fmt.Fprintf(os.Stderr, "[acp] session %s: previous conversation not archived: %v\n", sid, err)
108 return118 return
109 }119 }
110 s.record.CreatedAt = time.Now().UTC()120 s.record.CreatedAt = time.Now().UTC()
121+ s.record.Errors = nil // they belong to the conversation just archived
111 fmt.Fprintf(os.Stderr, "[acp] session %s: previous conversation kept as %s\n", sid, old.ID)122 fmt.Fprintf(os.Stderr, "[acp] session %s: previous conversation kept as %s\n", sid, old.ID)
112 }123 }
modified internal/acp/persist_test.go +36 -0
@@ -239,3 +239,39 @@ func TestArchiveKeepsTheOldConversationUnderANewID(t *testing.T) {
239239 t.Errorf("archiving an empty conversation created a file: %d sessions", got)
240240 }
241241 }
242+
243+// An error shown to the editor goes into the session's file, and /new moves
244+// it to the archived conversation with the history it belongs to: the fresh
245+// session starts with no errors.
246+func TestFailIsPersistedAndArchivedWithItsConversation(t *testing.T) {
247+ withSessions(t)
248+ cwd := t.TempDir()
249+ f := &front{sessionsDir: ".mm/sessions", system: "sys"}
250+ s := &session{cwd: cwd, messages: storedHistory(), allowAlways: map[string]bool{}}
251+ f.persistNew("id-1", s)
252+ f.fail("id-1", s, store.NewFailure(s.messages, store.FailGeneration, "the server is down", nil))
253+
254+ rec, err := s.st.Load("id-1")
255+ if err != nil {
256+ t.Fatal(err)
257+ }
258+ if len(rec.Errors) != 1 || rec.Errors[0].Question != "list the files" || len(rec.Messages) != len(storedHistory()) {
259+ t.Fatalf("stored %+v", rec)
260+ }
261+
262+ f.archive("id-1", s)
263+ resetSession(s, "sys")
264+ f.persist("id-1", s)
265+ current, _ := s.st.Load("id-1")
266+ if len(current.Errors) != 0 {
267+ t.Errorf("the fresh session kept the old errors: %+v", current.Errors)
268+ }
269+ for _, sum := range s.st.List() {
270+ if sum.ID != "id-1" && sum.Errors != 1 {
271+ t.Errorf("archived conversation lost its error: %+v", sum)
272+ }
273+ }
274+
275+ // Without persistence, fail is a no-op.
276+ f.fail("id-2", &session{cwd: cwd}, store.NewFailure(nil, store.FailGeneration, "x", nil))
277+}
@@ -239,3 +239,39 @@ func TestArchiveKeepsTheOldConversationUnderANewID(t *testing.T) {
239 t.Errorf("archiving an empty conversation created a file: %d sessions", got)239 t.Errorf("archiving an empty conversation created a file: %d sessions", got)
240 }240 }
241 }241 }
242+
243+// An error shown to the editor goes into the session's file, and /new moves
244+// it to the archived conversation with the history it belongs to: the fresh
245+// session starts with no errors.
246+func TestFailIsPersistedAndArchivedWithItsConversation(t *testing.T) {
247+ withSessions(t)
248+ cwd := t.TempDir()
249+ f := &front{sessionsDir: ".mm/sessions", system: "sys"}
250+ s := &session{cwd: cwd, messages: storedHistory(), allowAlways: map[string]bool{}}
251+ f.persistNew("id-1", s)
252+ f.fail("id-1", s, store.NewFailure(s.messages, store.FailGeneration, "the server is down", nil))
253+
254+ rec, err := s.st.Load("id-1")
255+ if err != nil {
256+ t.Fatal(err)
257+ }
258+ if len(rec.Errors) != 1 || rec.Errors[0].Question != "list the files" || len(rec.Messages) != len(storedHistory()) {
259+ t.Fatalf("stored %+v", rec)
260+ }
261+
262+ f.archive("id-1", s)
263+ resetSession(s, "sys")
264+ f.persist("id-1", s)
265+ current, _ := s.st.Load("id-1")
266+ if len(current.Errors) != 0 {
267+ t.Errorf("the fresh session kept the old errors: %+v", current.Errors)
268+ }
269+ for _, sum := range s.st.List() {
270+ if sum.ID != "id-1" && sum.Errors != 1 {
271+ t.Errorf("archived conversation lost its error: %+v", sum)
272+ }
273+ }
274+
275+ // Without persistence, fail is a no-op.
276+ f.fail("id-2", &session{cwd: cwd}, store.NewFailure(nil, store.FailGeneration, "x", nil))
277+}
modified internal/agent/agent.go +26 -7
@@ -166,7 +166,7 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
166166 }
167167 }
168168 if dec := compact.Decide(messages, e.LastInputTokens(), e.ContextWindow, config.Cfg.Context); dec.Compact {
169- messages = compactHistory(ctx, e, messages, false)
169+ messages = compactHistory(ctx, e, keep, messages, false)
170170 save(keep, messages)
171171 }
172172 // "@path" in the question names a file for the model to look at. The
@@ -206,6 +206,7 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
206206 // overrun or a silence caught by the watchdog, all the work of
207207 // the turn is in `history`, and Genkit itself returns nothing.
208208 // (resp.History() will not do: empty on the streamed path.)
209+ asked := messages // question included, for the error record below
209210 messages = session.AfterTurn(messages, before, res.resp, res.history, res.err)
210211 save(keep, messages)
211212
@@ -213,8 +214,12 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
213214 if genCtx.Err() == context.Canceled {
214215 fmt.Println("\n🛑 Generation aborted.")
215216 } else {
216- // One line, in the provider's words: see engine.Explain.
217- fmt.Println("\n[error: " + e.Explain(res.err) + "]")
217+ // One line, in the provider's words: see engine.Explain —
218+ // and the same line in the session file, for whoever
219+ // asks later why this turn failed.
220+ explained := e.Explain(res.err)
221+ fmt.Println("\n[error: " + explained + "]")
222+ record(keep, messages, store.NewFailure(asked, store.FailGeneration, explained, res.err))
218223 }
219224 } else {
220225 // --- Global Loop Detection ---
@@ -290,11 +295,14 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
290295 // Same principle as above: the commands already run stay in
291296 // the history, otherwise the next question starts again
292297 // without knowing what was done.
298+ asked := messages
293299 messages = session.AfterTurn(messages, before, res.resp, res.history, res.err)
300+ save(keep, messages)
294301 if res.err != nil && genCtx.Err() != context.Canceled {
295- fmt.Println("[error: " + e.Explain(res.err) + "]")
302+ explained := e.Explain(res.err)
303+ fmt.Println("[error: " + explained + "]")
304+ record(keep, messages, store.NewFailure(asked, store.FailGeneration, explained, res.err))
296305 }
297- save(keep, messages)
298306 generationRunning = false
299307 generating = false
300308
@@ -361,7 +369,7 @@ func oneLine(s string) string {
361369 // anything older than the kept turns" check, and it says so when there is not.
362370 // The loop detector is deliberately not touched: it records what the agent
363371 // DOES, and forgetting a conversation does not make a repeated command new.
364-func compactHistory(ctx context.Context, e *engine.Engine, msgs []*ai.Message, forced bool) []*ai.Message {
372+func compactHistory(ctx context.Context, e *engine.Engine, keep *store.Keeper, msgs []*ai.Message, forced bool) []*ai.Message {
365373 cfg := config.Cfg.Context
366374
367375 // The summary is the longest prefill of the session and prints nothing
@@ -380,6 +388,7 @@ func compactHistory(ctx context.Context, e *engine.Engine, msgs []*ai.Message, f
380388 }
381389 case compact.Failed:
382390 fmt.Println(line)
391+ record(keep, msgs, store.NewFailure(msgs, store.FailCompaction, line, nil))
383392 case compact.Compressed:
384393 // The server's last measure described the history we just replaced.
385394 e.ForgetInputTokens()
@@ -398,6 +407,15 @@ func save(keep *store.Keeper, msgs []*ai.Message) {
398407 }
399408 }
400409
410+// record writes the error just printed into the session file (see
411+// store.Keeper.Fail): the red line on screen is gone with the scrollback,
412+// the file keeps it for whoever investigates the run later.
413+func record(keep *store.Keeper, msgs []*ai.Message, f store.Failure) {
414+ if err := keep.Fail(msgs, f); err != nil {
415+ fmt.Println("[sessions: error not recorded: " + err.Error() + "]")
416+ }
417+}
418+
401419 // startNewSession forgets the conversation and returns the history of a fresh
402420 // one: the system prompt alone. Three things make up "the session" here, and
403421 // all three are reset — the messages, the server's token count of the last
@@ -448,7 +466,7 @@ func (l *loop) historyCommand(input string, messages []*ai.Message) ([]*ai.Messa
448466 }
449467 switch cmd.Name {
450468 case command.Compact:
451- messages = compactHistory(l.ctx, l.e, messages, true)
469+ messages = compactHistory(l.ctx, l.e, l.keep, messages, true)
452470 save(l.keep, messages)
453471 case command.New:
454472 messages = startNewSession(l.e, l.d, l.system, messages)
@@ -463,6 +481,7 @@ func (l *loop) historyCommand(input string, messages []*ai.Message) ([]*ai.Messa
463481 resumed, line, err := l.keep.Resume(arg, l.system)
464482 if err != nil {
465483 fmt.Println("⚠️ " + err.Error())
484+ record(l.keep, messages, store.NewFailure(messages, store.FailResume, err.Error(), err))
466485 break
467486 }
468487 messages = resumed
@@ -166,7 +166,7 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
166 }166 }
167 }167 }
168 if dec := compact.Decide(messages, e.LastInputTokens(), e.ContextWindow, config.Cfg.Context); dec.Compact {168 if dec := compact.Decide(messages, e.LastInputTokens(), e.ContextWindow, config.Cfg.Context); dec.Compact {
169- messages = compactHistory(ctx, e, messages, false)169+ messages = compactHistory(ctx, e, keep, messages, false)
170 save(keep, messages)170 save(keep, messages)
171 }171 }
172 // "@path" in the question names a file for the model to look at. The172 // "@path" in the question names a file for the model to look at. The
@@ -206,6 +206,7 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
206 // overrun or a silence caught by the watchdog, all the work of206 // overrun or a silence caught by the watchdog, all the work of
207 // the turn is in `history`, and Genkit itself returns nothing.207 // the turn is in `history`, and Genkit itself returns nothing.
208 // (resp.History() will not do: empty on the streamed path.)208 // (resp.History() will not do: empty on the streamed path.)
209+ asked := messages // question included, for the error record below
209 messages = session.AfterTurn(messages, before, res.resp, res.history, res.err)210 messages = session.AfterTurn(messages, before, res.resp, res.history, res.err)
210 save(keep, messages)211 save(keep, messages)
211 212
@@ -213,8 +214,12 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
213 if genCtx.Err() == context.Canceled {214 if genCtx.Err() == context.Canceled {
214 fmt.Println("\n🛑 Generation aborted.")215 fmt.Println("\n🛑 Generation aborted.")
215 } else {216 } else {
216- // One line, in the provider's words: see engine.Explain.217+ // One line, in the provider's words: see engine.Explain —
217- fmt.Println("\n[error: " + e.Explain(res.err) + "]")218+ // and the same line in the session file, for whoever
219+ // asks later why this turn failed.
220+ explained := e.Explain(res.err)
221+ fmt.Println("\n[error: " + explained + "]")
222+ record(keep, messages, store.NewFailure(asked, store.FailGeneration, explained, res.err))
218 }223 }
219 } else {224 } else {
220 // --- Global Loop Detection ---225 // --- Global Loop Detection ---
@@ -290,11 +295,14 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
290 // Same principle as above: the commands already run stay in295 // Same principle as above: the commands already run stay in
291 // the history, otherwise the next question starts again296 // the history, otherwise the next question starts again
292 // without knowing what was done.297 // without knowing what was done.
298+ asked := messages
293 messages = session.AfterTurn(messages, before, res.resp, res.history, res.err)299 messages = session.AfterTurn(messages, before, res.resp, res.history, res.err)
300+ save(keep, messages)
294 if res.err != nil && genCtx.Err() != context.Canceled {301 if res.err != nil && genCtx.Err() != context.Canceled {
295- fmt.Println("[error: " + e.Explain(res.err) + "]")302+ explained := e.Explain(res.err)
303+ fmt.Println("[error: " + explained + "]")
304+ record(keep, messages, store.NewFailure(asked, store.FailGeneration, explained, res.err))
296 }305 }
297- save(keep, messages)
298 generationRunning = false306 generationRunning = false
299 generating = false307 generating = false
300 308
@@ -361,7 +369,7 @@ func oneLine(s string) string {
361 // anything older than the kept turns" check, and it says so when there is not.369 // anything older than the kept turns" check, and it says so when there is not.
362 // The loop detector is deliberately not touched: it records what the agent370 // The loop detector is deliberately not touched: it records what the agent
363 // DOES, and forgetting a conversation does not make a repeated command new.371 // DOES, and forgetting a conversation does not make a repeated command new.
364-func compactHistory(ctx context.Context, e *engine.Engine, msgs []*ai.Message, forced bool) []*ai.Message {372+func compactHistory(ctx context.Context, e *engine.Engine, keep *store.Keeper, msgs []*ai.Message, forced bool) []*ai.Message {
365 cfg := config.Cfg.Context373 cfg := config.Cfg.Context
366 374
367 // The summary is the longest prefill of the session and prints nothing375 // The summary is the longest prefill of the session and prints nothing
@@ -380,6 +388,7 @@ func compactHistory(ctx context.Context, e *engine.Engine, msgs []*ai.Message, f
380 }388 }
381 case compact.Failed:389 case compact.Failed:
382 fmt.Println(line)390 fmt.Println(line)
391+ record(keep, msgs, store.NewFailure(msgs, store.FailCompaction, line, nil))
383 case compact.Compressed:392 case compact.Compressed:
384 // The server's last measure described the history we just replaced.393 // The server's last measure described the history we just replaced.
385 e.ForgetInputTokens()394 e.ForgetInputTokens()
@@ -398,6 +407,15 @@ func save(keep *store.Keeper, msgs []*ai.Message) {
398 }407 }
399 }408 }
400 409
410+// record writes the error just printed into the session file (see
411+// store.Keeper.Fail): the red line on screen is gone with the scrollback,
412+// the file keeps it for whoever investigates the run later.
413+func record(keep *store.Keeper, msgs []*ai.Message, f store.Failure) {
414+ if err := keep.Fail(msgs, f); err != nil {
415+ fmt.Println("[sessions: error not recorded: " + err.Error() + "]")
416+ }
417+}
418+
401 // startNewSession forgets the conversation and returns the history of a fresh419 // startNewSession forgets the conversation and returns the history of a fresh
402 // one: the system prompt alone. Three things make up "the session" here, and420 // one: the system prompt alone. Three things make up "the session" here, and
403 // all three are reset — the messages, the server's token count of the last421 // all three are reset — the messages, the server's token count of the last
@@ -448,7 +466,7 @@ func (l *loop) historyCommand(input string, messages []*ai.Message) ([]*ai.Messa
448 }466 }
449 switch cmd.Name {467 switch cmd.Name {
450 case command.Compact:468 case command.Compact:
451- messages = compactHistory(l.ctx, l.e, messages, true)469+ messages = compactHistory(l.ctx, l.e, l.keep, messages, true)
452 save(l.keep, messages)470 save(l.keep, messages)
453 case command.New:471 case command.New:
454 messages = startNewSession(l.e, l.d, l.system, messages)472 messages = startNewSession(l.e, l.d, l.system, messages)
@@ -463,6 +481,7 @@ func (l *loop) historyCommand(input string, messages []*ai.Message) ([]*ai.Messa
463 resumed, line, err := l.keep.Resume(arg, l.system)481 resumed, line, err := l.keep.Resume(arg, l.system)
464 if err != nil {482 if err != nil {
465 fmt.Println("⚠️ " + err.Error())483 fmt.Println("⚠️ " + err.Error())
484+ record(l.keep, messages, store.NewFailure(messages, store.FailResume, err.Error(), err))
466 break485 break
467 }486 }
468 messages = resumed487 messages = resumed
modified internal/store/keeper.go +29 -2
@@ -5,6 +5,7 @@ import (
55 "fmt"
66 "path/filepath"
77 "strings"
8+ "time"
89
910 "mm/internal/config"
1011 "mm/internal/session"
@@ -70,6 +71,23 @@ func (k *Keeper) Save(msgs []*ai.Message) error {
7071 return k.st.Save(k.rec)
7172 }
7273
74+// Fail records an error the front end just showed (see NewFailure) and
75+// rewrites the file with msgs, the history to keep. A failure on the very
76+// first question still opens the session's file: a run that only failed
77+// leaves the one trace worth reading. A nil Keeper records nothing.
78+//
79+// keep.Fail(messages, store.NewFailure(asked, store.FailGeneration, e.Explain(err), err))
80+func (k *Keeper) Fail(msgs []*ai.Message, f Failure) error {
81+ if k == nil {
82+ return nil
83+ }
84+ if k.rec == nil {
85+ k.rec = &Session{ID: NewID(), Cwd: k.cwd, CreatedAt: time.Now().UTC()}
86+ }
87+ k.rec.Fail(f)
88+ return k.Save(msgs)
89+}
90+
7391 // Renew is /new: the old file stays as it is; the next Save opens a new one.
7492 func (k *Keeper) Renew() {
7593 if k != nil {
@@ -157,7 +175,7 @@ func (k *Keeper) Listing() string {
157175 if k.rec != nil && s.ID == k.rec.ID {
158176 mark = "*"
159177 }
160- fmt.Fprintf(&b, "%s %s %s %d turn(s) %s\n", mark, s.ID, s.UpdatedAt.Local().Format("2006-01-02 15:04"), s.Turns, s.FirstQuestion)
178+ fmt.Fprintf(&b, "%s %s %s %d turn(s) %s%s\n", mark, s.ID, s.UpdatedAt.Local().Format("2006-01-02 15:04"), s.Turns, s.FirstQuestion, errorsMark(s.Errors))
161179 }
162180 return strings.TrimRight(b.String(), "\n")
163181 }
@@ -185,5 +203,14 @@ func describe(rec *Session) string {
185203 break
186204 }
187205 }
188- return fmt.Sprintf("%d message(s), started with %q", turns, first)
206+ return fmt.Sprintf("%d message(s), started with %q%s", turns, first, errorsMark(len(rec.Errors)))
207+}
208+
209+// errorsMark is the " · ⚠️ N error(s)" tail of a listing line or a resume
210+// report, "" when the session recorded none.
211+func errorsMark(n int) string {
212+ if n == 0 {
213+ return ""
214+ }
215+ return fmt.Sprintf(" · ⚠️ %d error(s)", n)
189216 }
@@ -5,6 +5,7 @@ import (
5 "fmt"5 "fmt"
6 "path/filepath"6 "path/filepath"
7 "strings"7 "strings"
8+ "time"
8 9
9 "mm/internal/config"10 "mm/internal/config"
10 "mm/internal/session"11 "mm/internal/session"
@@ -70,6 +71,23 @@ func (k *Keeper) Save(msgs []*ai.Message) error {
70 return k.st.Save(k.rec)71 return k.st.Save(k.rec)
71 }72 }
72 73
74+// Fail records an error the front end just showed (see NewFailure) and
75+// rewrites the file with msgs, the history to keep. A failure on the very
76+// first question still opens the session's file: a run that only failed
77+// leaves the one trace worth reading. A nil Keeper records nothing.
78+//
79+// keep.Fail(messages, store.NewFailure(asked, store.FailGeneration, e.Explain(err), err))
80+func (k *Keeper) Fail(msgs []*ai.Message, f Failure) error {
81+ if k == nil {
82+ return nil
83+ }
84+ if k.rec == nil {
85+ k.rec = &Session{ID: NewID(), Cwd: k.cwd, CreatedAt: time.Now().UTC()}
86+ }
87+ k.rec.Fail(f)
88+ return k.Save(msgs)
89+}
90+
73 // Renew is /new: the old file stays as it is; the next Save opens a new one.91 // Renew is /new: the old file stays as it is; the next Save opens a new one.
74 func (k *Keeper) Renew() {92 func (k *Keeper) Renew() {
75 if k != nil {93 if k != nil {
@@ -157,7 +175,7 @@ func (k *Keeper) Listing() string {
157 if k.rec != nil && s.ID == k.rec.ID {175 if k.rec != nil && s.ID == k.rec.ID {
158 mark = "*"176 mark = "*"
159 }177 }
160- fmt.Fprintf(&b, "%s %s %s %d turn(s) %s\n", mark, s.ID, s.UpdatedAt.Local().Format("2006-01-02 15:04"), s.Turns, s.FirstQuestion)178+ fmt.Fprintf(&b, "%s %s %s %d turn(s) %s%s\n", mark, s.ID, s.UpdatedAt.Local().Format("2006-01-02 15:04"), s.Turns, s.FirstQuestion, errorsMark(s.Errors))
161 }179 }
162 return strings.TrimRight(b.String(), "\n")180 return strings.TrimRight(b.String(), "\n")
163 }181 }
@@ -185,5 +203,14 @@ func describe(rec *Session) string {
185 break203 break
186 }204 }
187 }205 }
188- return fmt.Sprintf("%d message(s), started with %q", turns, first)206+ return fmt.Sprintf("%d message(s), started with %q%s", turns, first, errorsMark(len(rec.Errors)))
207+}
208+
209+// errorsMark is the " · ⚠️ N error(s)" tail of a listing line or a resume
210+// report, "" when the session recorded none.
211+func errorsMark(n int) string {
212+ if n == 0 {
213+ return ""
214+ }
215+ return fmt.Sprintf(" · ⚠️ %d error(s)", n)
189 }216 }
modified internal/store/keeper_test.go +55 -0
@@ -1,6 +1,7 @@
11 package store
22
33 import (
4+ "errors"
45 "os"
56 "path/filepath"
67 "strings"
@@ -138,3 +139,57 @@ func TestKeeperResumeAndListing(t *testing.T) {
138139 t.Errorf("listing:\n%s", got)
139140 }
140141 }
142+
143+// Fail writes the error into the current session's file — opening it when
144+// the run has not saved yet — and the record follows the session through a
145+// /resume, where the report and the listing count it. A nil Keeper records
146+// nothing.
147+func TestKeeperFail(t *testing.T) {
148+ withSessions(t, false)
149+ var none *Keeper
150+ if err := none.Fail(nil, NewFailure(nil, FailGeneration, "x", nil)); err != nil {
151+ t.Errorf("Fail on nil keeper: %v", err)
152+ }
153+
154+ withSessions(t, true)
155+ cwd := t.TempDir()
156+ k := NewKeeper(cwd)
157+ msgs := append(session.Fresh("sys"), ai.NewUserTextMessage("first question"))
158+ if err := k.Fail(msgs, NewFailure(msgs, FailGeneration, "the server is down", errors.New("connection refused"))); err != nil {
159+ t.Fatal(err)
160+ }
161+ if k.rec == nil {
162+ t.Fatal("no session opened by the first failure")
163+ }
164+ first := k.rec.ID
165+ got, err := k.st.Load(first)
166+ if err != nil {
167+ t.Fatal(err)
168+ }
169+ if len(got.Errors) != 1 || got.Errors[0].Question != "first question" || got.Errors[0].Turn != 1 || len(got.Messages) != 2 {
170+ t.Fatalf("stored %+v", got)
171+ }
172+
173+ // A later save keeps the record; /new then /resume brings it back, and
174+ // both the report and the listing say so.
175+ if err := k.Save(talk(session.Fresh("sys"), "first question")); err != nil {
176+ t.Fatal(err)
177+ }
178+ k.Renew()
179+ if err := k.Save(talk(session.Fresh("sys"), "second question")); err != nil {
180+ t.Fatal(err)
181+ }
182+ resumed, line, err := k.Resume(first, "sys")
183+ if err != nil {
184+ t.Fatal(err)
185+ }
186+ if len(resumed) != 3 || !strings.Contains(line, "· ⚠️ 1 error(s)") {
187+ t.Errorf("resumed %d messages, line %q", len(resumed), line)
188+ }
189+ if len(k.rec.Errors) != 1 {
190+ t.Errorf("the record lost its errors on resume: %+v", k.rec.Errors)
191+ }
192+ if got := k.Listing(); !strings.Contains(got, first+" ") || !strings.Contains(got, "first question · ⚠️ 1 error(s)") {
193+ t.Errorf("listing:\n%s", got)
194+ }
195+}
@@ -1,6 +1,7 @@
1 package store1 package store
2 2
3 import (3 import (
4+ "errors"
4 "os"5 "os"
5 "path/filepath"6 "path/filepath"
6 "strings"7 "strings"
@@ -138,3 +139,57 @@ func TestKeeperResumeAndListing(t *testing.T) {
138 t.Errorf("listing:\n%s", got)139 t.Errorf("listing:\n%s", got)
139 }140 }
140 }141 }
142+
143+// Fail writes the error into the current session's file — opening it when
144+// the run has not saved yet — and the record follows the session through a
145+// /resume, where the report and the listing count it. A nil Keeper records
146+// nothing.
147+func TestKeeperFail(t *testing.T) {
148+ withSessions(t, false)
149+ var none *Keeper
150+ if err := none.Fail(nil, NewFailure(nil, FailGeneration, "x", nil)); err != nil {
151+ t.Errorf("Fail on nil keeper: %v", err)
152+ }
153+
154+ withSessions(t, true)
155+ cwd := t.TempDir()
156+ k := NewKeeper(cwd)
157+ msgs := append(session.Fresh("sys"), ai.NewUserTextMessage("first question"))
158+ if err := k.Fail(msgs, NewFailure(msgs, FailGeneration, "the server is down", errors.New("connection refused"))); err != nil {
159+ t.Fatal(err)
160+ }
161+ if k.rec == nil {
162+ t.Fatal("no session opened by the first failure")
163+ }
164+ first := k.rec.ID
165+ got, err := k.st.Load(first)
166+ if err != nil {
167+ t.Fatal(err)
168+ }
169+ if len(got.Errors) != 1 || got.Errors[0].Question != "first question" || got.Errors[0].Turn != 1 || len(got.Messages) != 2 {
170+ t.Fatalf("stored %+v", got)
171+ }
172+
173+ // A later save keeps the record; /new then /resume brings it back, and
174+ // both the report and the listing say so.
175+ if err := k.Save(talk(session.Fresh("sys"), "first question")); err != nil {
176+ t.Fatal(err)
177+ }
178+ k.Renew()
179+ if err := k.Save(talk(session.Fresh("sys"), "second question")); err != nil {
180+ t.Fatal(err)
181+ }
182+ resumed, line, err := k.Resume(first, "sys")
183+ if err != nil {
184+ t.Fatal(err)
185+ }
186+ if len(resumed) != 3 || !strings.Contains(line, "· ⚠️ 1 error(s)") {
187+ t.Errorf("resumed %d messages, line %q", len(resumed), line)
188+ }
189+ if len(k.rec.Errors) != 1 {
190+ t.Errorf("the record lost its errors on resume: %+v", k.rec.Errors)
191+ }
192+ if got := k.Listing(); !strings.Contains(got, first+" ") || !strings.Contains(got, "first question · ⚠️ 1 error(s)") {
193+ t.Errorf("listing:\n%s", got)
194+ }
195+}
modified internal/store/store.go +83 -2
@@ -33,13 +33,71 @@ import (
3333 )
3434
3535 // Session is what one file holds. Messages is the full history, system
36-// prompt first, exactly as the front end keeps it in memory.
36+// prompt first, exactly as the front end keeps it in memory. Errors is the
37+// trail of what went wrong along the way — a failed generation, a failed
38+// compaction, a refused /resume — kept OUTSIDE the history: the model never
39+// reads it, a /resume or a session/load never replays it, and it survives a
40+// compaction, which rewrites Messages alone. It is there for whoever
41+// investigates a session afterwards (`jq .errors <file>`, or a coding agent
42+// asked why the run went wrong and what to change in the code or the YAML).
3743 type Session struct {
3844 ID string `json:"id"`
3945 Cwd string `json:"cwd"`
4046 CreatedAt time.Time `json:"createdAt"`
4147 UpdatedAt time.Time `json:"updatedAt"`
4248 Messages []*ai.Message `json:"messages"`
49+ Errors []Failure `json:"errors,omitempty"`
50+}
51+
52+// Failure is one error a front end showed the user, as recorded in the
53+// session file. Message is the line the user saw (engine.Explain's words,
54+// without the brackets); Detail is the raw Go error when it says more than
55+// the line; Question is the first line of the question being answered, so a
56+// reader can tell which turn failed without counting messages.
57+type Failure struct {
58+ At time.Time `json:"at"`
59+ Turn int `json:"turn"` // user questions in the history when it happened
60+ Where string `json:"where"` // FailGeneration, FailCompaction or FailResume
61+ Message string `json:"message"`
62+ Detail string `json:"detail,omitempty"`
63+ Question string `json:"question,omitempty"`
64+}
65+
66+// The places a Failure.Where can name.
67+const (
68+ FailGeneration = "generation" // the model's answer to a question (engine.Generate)
69+ FailCompaction = "compaction" // the summary request of a compaction (automatic or /compact)
70+ FailResume = "resume" // a /resume that could not load its session
71+)
72+
73+// maxErrors bounds the trail: a session that fails on every turn must not
74+// grow its file without end. The oldest records go first.
75+const maxErrors = 200
76+
77+// NewFailure builds the record of an error, stamped with the time and with
78+// the turn count and last question taken from msgs — the history AS IT WAS
79+// when the error happened, question included: after a failed generation the
80+// front ends drop the unanswered question from the history they keep, and
81+// the record is where it survives. shown is the line the user saw; err may
82+// be nil (a failure known by its line only) and is kept as Detail when it
83+// adds something to shown.
84+//
85+// f := store.NewFailure(asked, store.FailGeneration, e.Explain(err), err)
86+func NewFailure(msgs []*ai.Message, where, shown string, err error) Failure {
87+ f := Failure{At: time.Now().UTC(), Turn: turns(msgs), Where: where, Message: shown, Question: lastQuestion(msgs)}
88+ if err != nil && !strings.Contains(shown, err.Error()) {
89+ f.Detail = err.Error()
90+ }
91+ return f
92+}
93+
94+// Fail appends one record to the session's trail, dropping the oldest past
95+// maxErrors.
96+func (sess *Session) Fail(f Failure) {
97+ sess.Errors = append(sess.Errors, f)
98+ if n := len(sess.Errors); n > maxErrors {
99+ sess.Errors = sess.Errors[n-maxErrors:]
100+ }
43101 }
44102
45103 // Summary is what a listing shows for one session, without loading it all.
@@ -47,6 +105,7 @@ type Summary struct {
47105 ID string
48106 UpdatedAt time.Time
49107 Turns int // user questions in the history
108+ Errors int // recorded failures (see Session.Errors)
50109 FirstQuestion string // the first user message, one line, trimmed
51110 }
52111
@@ -190,7 +249,7 @@ func validID(id string) error {
190249 }
191250
192251 func summarize(sess *Session) Summary {
193- sum := Summary{ID: sess.ID, UpdatedAt: sess.UpdatedAt}
252+ sum := Summary{ID: sess.ID, UpdatedAt: sess.UpdatedAt, Errors: len(sess.Errors)}
194253 for _, m := range sess.Messages {
195254 if m == nil || m.Role != ai.RoleUser {
196255 continue
@@ -203,6 +262,28 @@ func summarize(sess *Session) Summary {
203262 return sum
204263 }
205264
265+// turns counts the user questions in msgs.
266+func turns(msgs []*ai.Message) int {
267+ n := 0
268+ for _, m := range msgs {
269+ if m != nil && m.Role == ai.RoleUser {
270+ n++
271+ }
272+ }
273+ return n
274+}
275+
276+// lastQuestion is the first line of the most recent user message, "" when
277+// there is none.
278+func lastQuestion(msgs []*ai.Message) string {
279+ for i := len(msgs) - 1; i >= 0; i-- {
280+ if m := msgs[i]; m != nil && m.Role == ai.RoleUser {
281+ return firstLine(m.Text())
282+ }
283+ }
284+ return ""
285+}
286+
206287 // firstLine is the first non-empty line of text, trimmed, at most 60 runes.
207288 func firstLine(text string) string {
208289 for _, line := range strings.Split(text, "\n") {
@@ -33,13 +33,71 @@ import (
33 )33 )
34 34
35 // Session is what one file holds. Messages is the full history, system35 // Session is what one file holds. Messages is the full history, system
36-// prompt first, exactly as the front end keeps it in memory.36+// prompt first, exactly as the front end keeps it in memory. Errors is the
37+// trail of what went wrong along the way — a failed generation, a failed
38+// compaction, a refused /resume — kept OUTSIDE the history: the model never
39+// reads it, a /resume or a session/load never replays it, and it survives a
40+// compaction, which rewrites Messages alone. It is there for whoever
41+// investigates a session afterwards (`jq .errors <file>`, or a coding agent
42+// asked why the run went wrong and what to change in the code or the YAML).
37 type Session struct {43 type Session struct {
38 ID string `json:"id"`44 ID string `json:"id"`
39 Cwd string `json:"cwd"`45 Cwd string `json:"cwd"`
40 CreatedAt time.Time `json:"createdAt"`46 CreatedAt time.Time `json:"createdAt"`
41 UpdatedAt time.Time `json:"updatedAt"`47 UpdatedAt time.Time `json:"updatedAt"`
42 Messages []*ai.Message `json:"messages"`48 Messages []*ai.Message `json:"messages"`
49+ Errors []Failure `json:"errors,omitempty"`
50+}
51+
52+// Failure is one error a front end showed the user, as recorded in the
53+// session file. Message is the line the user saw (engine.Explain's words,
54+// without the brackets); Detail is the raw Go error when it says more than
55+// the line; Question is the first line of the question being answered, so a
56+// reader can tell which turn failed without counting messages.
57+type Failure struct {
58+ At time.Time `json:"at"`
59+ Turn int `json:"turn"` // user questions in the history when it happened
60+ Where string `json:"where"` // FailGeneration, FailCompaction or FailResume
61+ Message string `json:"message"`
62+ Detail string `json:"detail,omitempty"`
63+ Question string `json:"question,omitempty"`
64+}
65+
66+// The places a Failure.Where can name.
67+const (
68+ FailGeneration = "generation" // the model's answer to a question (engine.Generate)
69+ FailCompaction = "compaction" // the summary request of a compaction (automatic or /compact)
70+ FailResume = "resume" // a /resume that could not load its session
71+)
72+
73+// maxErrors bounds the trail: a session that fails on every turn must not
74+// grow its file without end. The oldest records go first.
75+const maxErrors = 200
76+
77+// NewFailure builds the record of an error, stamped with the time and with
78+// the turn count and last question taken from msgs — the history AS IT WAS
79+// when the error happened, question included: after a failed generation the
80+// front ends drop the unanswered question from the history they keep, and
81+// the record is where it survives. shown is the line the user saw; err may
82+// be nil (a failure known by its line only) and is kept as Detail when it
83+// adds something to shown.
84+//
85+// f := store.NewFailure(asked, store.FailGeneration, e.Explain(err), err)
86+func NewFailure(msgs []*ai.Message, where, shown string, err error) Failure {
87+ f := Failure{At: time.Now().UTC(), Turn: turns(msgs), Where: where, Message: shown, Question: lastQuestion(msgs)}
88+ if err != nil && !strings.Contains(shown, err.Error()) {
89+ f.Detail = err.Error()
90+ }
91+ return f
92+}
93+
94+// Fail appends one record to the session's trail, dropping the oldest past
95+// maxErrors.
96+func (sess *Session) Fail(f Failure) {
97+ sess.Errors = append(sess.Errors, f)
98+ if n := len(sess.Errors); n > maxErrors {
99+ sess.Errors = sess.Errors[n-maxErrors:]
100+ }
43 }101 }
44 102
45 // Summary is what a listing shows for one session, without loading it all.103 // Summary is what a listing shows for one session, without loading it all.
@@ -47,6 +105,7 @@ type Summary struct {
47 ID string105 ID string
48 UpdatedAt time.Time106 UpdatedAt time.Time
49 Turns int // user questions in the history107 Turns int // user questions in the history
108+ Errors int // recorded failures (see Session.Errors)
50 FirstQuestion string // the first user message, one line, trimmed109 FirstQuestion string // the first user message, one line, trimmed
51 }110 }
52 111
@@ -190,7 +249,7 @@ func validID(id string) error {
190 }249 }
191 250
192 func summarize(sess *Session) Summary {251 func summarize(sess *Session) Summary {
193- sum := Summary{ID: sess.ID, UpdatedAt: sess.UpdatedAt}252+ sum := Summary{ID: sess.ID, UpdatedAt: sess.UpdatedAt, Errors: len(sess.Errors)}
194 for _, m := range sess.Messages {253 for _, m := range sess.Messages {
195 if m == nil || m.Role != ai.RoleUser {254 if m == nil || m.Role != ai.RoleUser {
196 continue255 continue
@@ -203,6 +262,28 @@ func summarize(sess *Session) Summary {
203 return sum262 return sum
204 }263 }
205 264
265+// turns counts the user questions in msgs.
266+func turns(msgs []*ai.Message) int {
267+ n := 0
268+ for _, m := range msgs {
269+ if m != nil && m.Role == ai.RoleUser {
270+ n++
271+ }
272+ }
273+ return n
274+}
275+
276+// lastQuestion is the first line of the most recent user message, "" when
277+// there is none.
278+func lastQuestion(msgs []*ai.Message) string {
279+ for i := len(msgs) - 1; i >= 0; i-- {
280+ if m := msgs[i]; m != nil && m.Role == ai.RoleUser {
281+ return firstLine(m.Text())
282+ }
283+ }
284+ return ""
285+}
286+
206 // firstLine is the first non-empty line of text, trimmed, at most 60 runes.287 // firstLine is the first non-empty line of text, trimmed, at most 60 runes.
207 func firstLine(text string) string {288 func firstLine(text string) string {
208 for _, line := range strings.Split(text, "\n") {289 for _, line := range strings.Split(text, "\n") {
modified internal/store/store_test.go +66 -0
@@ -144,3 +144,69 @@ func TestListAndLatest(t *testing.T) {
144144 t.Errorf("Latest = %v, %v; want %s", latest, err, recent.ID)
145145 }
146146 }
147+
148+// Errors live next to the history, not in it: Fail stamps the turn and the
149+// question being answered, keeps the raw error only when it adds something,
150+// survives a save/load round trip, is counted by the listing, and is
151+// bounded.
152+func TestFailIsRecordedOutsideTheHistory(t *testing.T) {
153+ st := Open(t.TempDir())
154+ msgs := history("a", "b")
155+ sess, err := st.New("/work", msgs)
156+ if err != nil {
157+ t.Fatal(err)
158+ }
159+ sess.Fail(NewFailure(msgs, FailGeneration, "the server is down", errors.New("dial tcp: connection refused")))
160+ sess.Fail(NewFailure(msgs, FailCompaction, "[compact: failed, history kept: boom]", errors.New("boom")))
161+ if err := st.Save(sess); err != nil {
162+ t.Fatal(err)
163+ }
164+
165+ got, err := st.Load(sess.ID)
166+ if err != nil {
167+ t.Fatal(err)
168+ }
169+ if len(got.Messages) != len(msgs) {
170+ t.Fatalf("the errors changed the history: %d messages, want %d", len(got.Messages), len(msgs))
171+ }
172+ if len(got.Errors) != 2 {
173+ t.Fatalf("%d errors after the round trip, want 2", len(got.Errors))
174+ }
175+ first := got.Errors[0]
176+ if first.Where != FailGeneration || first.Turn != 2 || first.Question != "b" || first.Message != "the server is down" || first.Detail != "dial tcp: connection refused" || first.At.IsZero() {
177+ t.Errorf("first record %+v", first)
178+ }
179+ if second := got.Errors[1]; second.Detail != "" {
180+ t.Errorf("detail %q repeats what the message already says", second.Detail)
181+ }
182+ if sum := summarize(got); sum.Errors != 2 || sum.Turns != 2 {
183+ t.Errorf("summary %+v, want 2 errors and 2 turns", sum)
184+ }
185+
186+ for i := 0; i < maxErrors+10; i++ {
187+ sess.Fail(NewFailure(msgs, FailGeneration, "again", nil))
188+ }
189+ if len(sess.Errors) != maxErrors || sess.Errors[0].Message != "again" {
190+ t.Errorf("%d errors kept, first %q: the trail must drop its oldest records at %d", len(sess.Errors), sess.Errors[0].Message, maxErrors)
191+ }
192+}
193+
194+// A file written before the errors field existed still loads, with no
195+// errors: an old session is not broken by the new field.
196+func TestOldFileWithoutErrorsLoads(t *testing.T) {
197+ st := Open(t.TempDir())
198+ if err := os.MkdirAll(st.Dir(), 0o755); err != nil {
199+ t.Fatal(err)
200+ }
201+ old := `{"id":"20260101-000000-00000000","cwd":"/w","createdAt":"2026-01-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z","messages":[{"role":"system","content":[{"text":"p"}]}]}`
202+ if err := os.WriteFile(filepath.Join(st.Dir(), "20260101-000000-00000000.json"), []byte(old), 0o644); err != nil {
203+ t.Fatal(err)
204+ }
205+ got, err := st.Load("20260101-000000-00000000")
206+ if err != nil {
207+ t.Fatal(err)
208+ }
209+ if len(got.Errors) != 0 || len(got.Messages) != 1 {
210+ t.Errorf("loaded %+v", got)
211+ }
212+}
@@ -144,3 +144,69 @@ func TestListAndLatest(t *testing.T) {
144 t.Errorf("Latest = %v, %v; want %s", latest, err, recent.ID)144 t.Errorf("Latest = %v, %v; want %s", latest, err, recent.ID)
145 }145 }
146 }146 }
147+
148+// Errors live next to the history, not in it: Fail stamps the turn and the
149+// question being answered, keeps the raw error only when it adds something,
150+// survives a save/load round trip, is counted by the listing, and is
151+// bounded.
152+func TestFailIsRecordedOutsideTheHistory(t *testing.T) {
153+ st := Open(t.TempDir())
154+ msgs := history("a", "b")
155+ sess, err := st.New("/work", msgs)
156+ if err != nil {
157+ t.Fatal(err)
158+ }
159+ sess.Fail(NewFailure(msgs, FailGeneration, "the server is down", errors.New("dial tcp: connection refused")))
160+ sess.Fail(NewFailure(msgs, FailCompaction, "[compact: failed, history kept: boom]", errors.New("boom")))
161+ if err := st.Save(sess); err != nil {
162+ t.Fatal(err)
163+ }
164+
165+ got, err := st.Load(sess.ID)
166+ if err != nil {
167+ t.Fatal(err)
168+ }
169+ if len(got.Messages) != len(msgs) {
170+ t.Fatalf("the errors changed the history: %d messages, want %d", len(got.Messages), len(msgs))
171+ }
172+ if len(got.Errors) != 2 {
173+ t.Fatalf("%d errors after the round trip, want 2", len(got.Errors))
174+ }
175+ first := got.Errors[0]
176+ if first.Where != FailGeneration || first.Turn != 2 || first.Question != "b" || first.Message != "the server is down" || first.Detail != "dial tcp: connection refused" || first.At.IsZero() {
177+ t.Errorf("first record %+v", first)
178+ }
179+ if second := got.Errors[1]; second.Detail != "" {
180+ t.Errorf("detail %q repeats what the message already says", second.Detail)
181+ }
182+ if sum := summarize(got); sum.Errors != 2 || sum.Turns != 2 {
183+ t.Errorf("summary %+v, want 2 errors and 2 turns", sum)
184+ }
185+
186+ for i := 0; i < maxErrors+10; i++ {
187+ sess.Fail(NewFailure(msgs, FailGeneration, "again", nil))
188+ }
189+ if len(sess.Errors) != maxErrors || sess.Errors[0].Message != "again" {
190+ t.Errorf("%d errors kept, first %q: the trail must drop its oldest records at %d", len(sess.Errors), sess.Errors[0].Message, maxErrors)
191+ }
192+}
193+
194+// A file written before the errors field existed still loads, with no
195+// errors: an old session is not broken by the new field.
196+func TestOldFileWithoutErrorsLoads(t *testing.T) {
197+ st := Open(t.TempDir())
198+ if err := os.MkdirAll(st.Dir(), 0o755); err != nil {
199+ t.Fatal(err)
200+ }
201+ old := `{"id":"20260101-000000-00000000","cwd":"/w","createdAt":"2026-01-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z","messages":[{"role":"system","content":[{"text":"p"}]}]}`
202+ if err := os.WriteFile(filepath.Join(st.Dir(), "20260101-000000-00000000.json"), []byte(old), 0o644); err != nil {
203+ t.Fatal(err)
204+ }
205+ got, err := st.Load("20260101-000000-00000000")
206+ if err != nil {
207+ t.Fatal(err)
208+ }
209+ if len(got.Errors) != 0 || len(got.Messages) != 1 {
210+ t.Errorf("loaded %+v", got)
211+ }
212+}
modified internal/tui/turn.go +32 -8
@@ -68,6 +68,10 @@ type outcome struct {
6868 cancelled bool
6969 line string
7070 changed bool
71+ // compactFailed is the line of an automatic compaction that failed
72+ // before the question was appended, "" otherwise; recorded in the
73+ // session file by the loop goroutine, which owns the keeper.
74+ compactFailed string
7175 }
7276
7377 // runner owns the session — history, engine, keeper, detector — and runs
@@ -194,6 +198,7 @@ func (r *runner) resume(id string) {
194198 msgs, line, err := r.keep.Resume(id, r.opts.System)
195199 if err != nil {
196200 r.t.Error("⚠️ " + err.Error())
201+ r.record(store.NewFailure(r.messages, store.FailResume, err.Error(), err))
197202 return
198203 }
199204 r.messages = msgs
@@ -223,7 +228,7 @@ func (r *runner) startTurn(p Prompt) {
223228 r.streamed.Store(false)
224229 msgs := append([]*ai.Message(nil), r.messages...)
225230 go func() {
226- msgs = r.compactIfDue(ctx, msgs)
231+ msgs, compactFailed := r.compactIfDue(ctx, msgs)
227232 msgs = append(msgs, ai.NewUserTextMessage(input))
228233 before := len(msgs)
229234 ui.SetActive(&sink{r: r})
@@ -232,18 +237,19 @@ func (r *runner) startTurn(p Prompt) {
232237 if ctx.Err() != nil && err == nil {
233238 err = ctx.Err()
234239 }
235- r.results <- outcome{kind: "turn", messages: msgs, before: before, resp: resp, history: history, err: err, cancelled: ctx.Err() != nil}
240+ r.results <- outcome{kind: "turn", messages: msgs, before: before, resp: resp, history: history, err: err, cancelled: ctx.Err() != nil, compactFailed: compactFailed}
236241 r.wake()
237242 }()
238243 }
239244
240245 // compactIfDue is the automatic compaction, the REPL's rule verbatim: the
241246 // server's own count of the last context beats the estimate, the window is
242-// re-probed while unknown, and a failure keeps the history.
243-func (r *runner) compactIfDue(ctx context.Context, msgs []*ai.Message) []*ai.Message {
247+// re-probed while unknown, and a failure keeps the history. The second
248+// result is the failure line when the summary failed, for the session file.
249+func (r *runner) compactIfDue(ctx context.Context, msgs []*ai.Message) ([]*ai.Message, string) {
244250 cfg := config.Cfg.Context
245251 if !cfg.Enabled {
246- return msgs
252+ return msgs, ""
247253 }
248254 if r.opts.Window() == 0 && r.opts.Ensure != nil {
249255 if w, src := r.opts.Ensure(ctx); w > 0 {
@@ -252,7 +258,7 @@ func (r *runner) compactIfDue(ctx context.Context, msgs []*ai.Message) []*ai.Mes
252258 }
253259 dec := compact.Decide(msgs, r.opts.Model.LastInputTokens(), r.opts.Window(), cfg)
254260 if !dec.Compact {
255- return msgs
261+ return msgs, ""
256262 }
257263 out, line, outcome := compact.Apply(ctx, msgs, cfg, r.summarize, r.opts.Model.Explain)
258264 switch outcome {
@@ -263,8 +269,9 @@ func (r *runner) compactIfDue(ctx context.Context, msgs []*ai.Message) []*ai.Mes
263269 }
264270 case compact.Failed:
265271 r.t.Error(line)
272+ return out, line
266273 }
267- return out
274+ return out, ""
268275 }
269276
270277 func (r *runner) summarize(ctx context.Context, request []*ai.Message) (string, error) {
@@ -315,6 +322,7 @@ func (r *runner) apply(res outcome) {
315322 switch {
316323 case res.err != nil:
317324 r.t.Error(res.line)
325+ r.record(store.NewFailure(r.messages, store.FailCompaction, res.line, nil))
318326 case res.changed:
319327 r.opts.Model.ForgetInputTokens()
320328 r.save()
@@ -326,6 +334,9 @@ func (r *runner) apply(res outcome) {
326334 }
327335 r.messages = session.AfterTurn(res.messages, res.before, res.resp, res.history, res.err)
328336 r.save()
337+ if res.compactFailed != "" {
338+ r.record(store.NewFailure(res.messages, store.FailCompaction, res.compactFailed, nil))
339+ }
329340 if !r.streamed.Load() && res.resp != nil && res.resp.Message != nil {
330341 r.t.Chunk(res.resp.Message.Text())
331342 }
@@ -334,7 +345,11 @@ func (r *runner) apply(res outcome) {
334345 case res.err != nil && res.cancelled:
335346 r.t.Notice("🛑 Generation aborted.")
336347 case res.err != nil:
337- r.t.Error("[error: " + r.opts.Model.Explain(res.err) + "]")
348+ explained := r.opts.Model.Explain(res.err)
349+ r.t.Error("[error: " + explained + "]")
350+ // Stamped with res.messages, question included: AfterTurn has just
351+ // dropped the unanswered question from the history.
352+ r.record(store.NewFailure(res.messages, store.FailGeneration, explained, res.err))
338353 default:
339354 r.recordCalls(res.resp)
340355 }
@@ -377,6 +392,15 @@ func (r *runner) save() {
377392 }
378393 }
379394
395+// record writes the error just shown into the session file (store.Keeper.Fail),
396+// so a later reader — or a coding agent — can see what went wrong and when.
397+// Called on the loop goroutine only, like save.
398+func (r *runner) record(f store.Failure) {
399+ if err := r.keep.Fail(r.messages, f); err != nil {
400+ r.t.Error("[sessions: error not recorded: " + err.Error() + "]")
401+ }
402+}
403+
380404 // header is the top line: who is answering, how full the context is, which
381405 // session is being saved.
382406 func (r *runner) header() string {
@@ -68,6 +68,10 @@ type outcome struct {
68 cancelled bool68 cancelled bool
69 line string69 line string
70 changed bool70 changed bool
71+ // compactFailed is the line of an automatic compaction that failed
72+ // before the question was appended, "" otherwise; recorded in the
73+ // session file by the loop goroutine, which owns the keeper.
74+ compactFailed string
71 }75 }
72 76
73 // runner owns the session — history, engine, keeper, detector — and runs77 // runner owns the session — history, engine, keeper, detector — and runs
@@ -194,6 +198,7 @@ func (r *runner) resume(id string) {
194 msgs, line, err := r.keep.Resume(id, r.opts.System)198 msgs, line, err := r.keep.Resume(id, r.opts.System)
195 if err != nil {199 if err != nil {
196 r.t.Error("⚠️ " + err.Error())200 r.t.Error("⚠️ " + err.Error())
201+ r.record(store.NewFailure(r.messages, store.FailResume, err.Error(), err))
197 return202 return
198 }203 }
199 r.messages = msgs204 r.messages = msgs
@@ -223,7 +228,7 @@ func (r *runner) startTurn(p Prompt) {
223 r.streamed.Store(false)228 r.streamed.Store(false)
224 msgs := append([]*ai.Message(nil), r.messages...)229 msgs := append([]*ai.Message(nil), r.messages...)
225 go func() {230 go func() {
226- msgs = r.compactIfDue(ctx, msgs)231+ msgs, compactFailed := r.compactIfDue(ctx, msgs)
227 msgs = append(msgs, ai.NewUserTextMessage(input))232 msgs = append(msgs, ai.NewUserTextMessage(input))
228 before := len(msgs)233 before := len(msgs)
229 ui.SetActive(&sink{r: r})234 ui.SetActive(&sink{r: r})
@@ -232,18 +237,19 @@ func (r *runner) startTurn(p Prompt) {
232 if ctx.Err() != nil && err == nil {237 if ctx.Err() != nil && err == nil {
233 err = ctx.Err()238 err = ctx.Err()
234 }239 }
235- r.results <- outcome{kind: "turn", messages: msgs, before: before, resp: resp, history: history, err: err, cancelled: ctx.Err() != nil}240+ r.results <- outcome{kind: "turn", messages: msgs, before: before, resp: resp, history: history, err: err, cancelled: ctx.Err() != nil, compactFailed: compactFailed}
236 r.wake()241 r.wake()
237 }()242 }()
238 }243 }
239 244
240 // compactIfDue is the automatic compaction, the REPL's rule verbatim: the245 // compactIfDue is the automatic compaction, the REPL's rule verbatim: the
241 // server's own count of the last context beats the estimate, the window is246 // server's own count of the last context beats the estimate, the window is
242-// re-probed while unknown, and a failure keeps the history.247+// re-probed while unknown, and a failure keeps the history. The second
243-func (r *runner) compactIfDue(ctx context.Context, msgs []*ai.Message) []*ai.Message {248+// result is the failure line when the summary failed, for the session file.
249+func (r *runner) compactIfDue(ctx context.Context, msgs []*ai.Message) ([]*ai.Message, string) {
244 cfg := config.Cfg.Context250 cfg := config.Cfg.Context
245 if !cfg.Enabled {251 if !cfg.Enabled {
246- return msgs252+ return msgs, ""
247 }253 }
248 if r.opts.Window() == 0 && r.opts.Ensure != nil {254 if r.opts.Window() == 0 && r.opts.Ensure != nil {
249 if w, src := r.opts.Ensure(ctx); w > 0 {255 if w, src := r.opts.Ensure(ctx); w > 0 {
@@ -252,7 +258,7 @@ func (r *runner) compactIfDue(ctx context.Context, msgs []*ai.Message) []*ai.Mes
252 }258 }
253 dec := compact.Decide(msgs, r.opts.Model.LastInputTokens(), r.opts.Window(), cfg)259 dec := compact.Decide(msgs, r.opts.Model.LastInputTokens(), r.opts.Window(), cfg)
254 if !dec.Compact {260 if !dec.Compact {
255- return msgs261+ return msgs, ""
256 }262 }
257 out, line, outcome := compact.Apply(ctx, msgs, cfg, r.summarize, r.opts.Model.Explain)263 out, line, outcome := compact.Apply(ctx, msgs, cfg, r.summarize, r.opts.Model.Explain)
258 switch outcome {264 switch outcome {
@@ -263,8 +269,9 @@ func (r *runner) compactIfDue(ctx context.Context, msgs []*ai.Message) []*ai.Mes
263 }269 }
264 case compact.Failed:270 case compact.Failed:
265 r.t.Error(line)271 r.t.Error(line)
272+ return out, line
266 }273 }
267- return out274+ return out, ""
268 }275 }
269 276
270 func (r *runner) summarize(ctx context.Context, request []*ai.Message) (string, error) {277 func (r *runner) summarize(ctx context.Context, request []*ai.Message) (string, error) {
@@ -315,6 +322,7 @@ func (r *runner) apply(res outcome) {
315 switch {322 switch {
316 case res.err != nil:323 case res.err != nil:
317 r.t.Error(res.line)324 r.t.Error(res.line)
325+ r.record(store.NewFailure(r.messages, store.FailCompaction, res.line, nil))
318 case res.changed:326 case res.changed:
319 r.opts.Model.ForgetInputTokens()327 r.opts.Model.ForgetInputTokens()
320 r.save()328 r.save()
@@ -326,6 +334,9 @@ func (r *runner) apply(res outcome) {
326 }334 }
327 r.messages = session.AfterTurn(res.messages, res.before, res.resp, res.history, res.err)335 r.messages = session.AfterTurn(res.messages, res.before, res.resp, res.history, res.err)
328 r.save()336 r.save()
337+ if res.compactFailed != "" {
338+ r.record(store.NewFailure(res.messages, store.FailCompaction, res.compactFailed, nil))
339+ }
329 if !r.streamed.Load() && res.resp != nil && res.resp.Message != nil {340 if !r.streamed.Load() && res.resp != nil && res.resp.Message != nil {
330 r.t.Chunk(res.resp.Message.Text())341 r.t.Chunk(res.resp.Message.Text())
331 }342 }
@@ -334,7 +345,11 @@ func (r *runner) apply(res outcome) {
334 case res.err != nil && res.cancelled:345 case res.err != nil && res.cancelled:
335 r.t.Notice("🛑 Generation aborted.")346 r.t.Notice("🛑 Generation aborted.")
336 case res.err != nil:347 case res.err != nil:
337- r.t.Error("[error: " + r.opts.Model.Explain(res.err) + "]")348+ explained := r.opts.Model.Explain(res.err)
349+ r.t.Error("[error: " + explained + "]")
350+ // Stamped with res.messages, question included: AfterTurn has just
351+ // dropped the unanswered question from the history.
352+ r.record(store.NewFailure(res.messages, store.FailGeneration, explained, res.err))
338 default:353 default:
339 r.recordCalls(res.resp)354 r.recordCalls(res.resp)
340 }355 }
@@ -377,6 +392,15 @@ func (r *runner) save() {
377 }392 }
378 }393 }
379 394
395+// record writes the error just shown into the session file (store.Keeper.Fail),
396+// so a later reader — or a coding agent — can see what went wrong and when.
397+// Called on the loop goroutine only, like save.
398+func (r *runner) record(f store.Failure) {
399+ if err := r.keep.Fail(r.messages, f); err != nil {
400+ r.t.Error("[sessions: error not recorded: " + err.Error() + "]")
401+ }
402+}
403+
380 // header is the top line: who is answering, how full the context is, which404 // header is the top line: who is answering, how full the context is, which
381 // session is being saved.405 // session is being saved.
382 func (r *runner) header() string {406 func (r *runner) header() string {
modified internal/tui/turn_test.go +31 -0
@@ -13,6 +13,7 @@ import (
1313 "mm/internal/config"
1414 "mm/internal/detector"
1515 "mm/internal/session"
16+ "mm/internal/store"
1617 "mm/internal/ui"
1718
1819 "github.com/firebase/genkit/go/ai"
@@ -394,3 +395,33 @@ func TestRunnerTurnWithPaste(t *testing.T) {
394395 t.Errorf("attachments echoed:\n%s", n)
395396 }
396397 }
398+
399+// With sessions on, the red line of a failed turn is also written into the
400+// session file — outside the history, so the model never reads it — and a
401+// failed /resume is recorded too.
402+func TestRunnerFailureIsRecordedInTheSession(t *testing.T) {
403+ r, _ := newTestRunner(t, &fakeModel{fail: errors.New("boom")})
404+ config.Cfg.Sessions = config.SessionsConfig{Enabled: true, Dir: ".mm/sessions"}
405+ r.keep = store.NewKeeper(r.opts.Cwd)
406+ r.submit(promptOf("q"))
407+ waitIdle(t, r)
408+ r.resume("nope")
409+
410+ id := r.keep.CurrentID()
411+ if id == "" {
412+ t.Fatal("no session file opened")
413+ }
414+ rec, err := store.Open(r.keep.Dir()).Load(id)
415+ if err != nil {
416+ t.Fatal(err)
417+ }
418+ if len(rec.Messages) != 1 {
419+ t.Errorf("history in the file has %d messages, want the prompt alone", len(rec.Messages))
420+ }
421+ if len(rec.Errors) != 2 || rec.Errors[0].Where != store.FailGeneration || rec.Errors[0].Message != "explained: boom" || rec.Errors[0].Question != "q" || rec.Errors[0].Turn != 1 || rec.Errors[1].Where != store.FailResume {
422+ t.Errorf("errors recorded: %+v", rec.Errors)
423+ }
424+ if rec.Errors[0].Detail != "" {
425+ t.Errorf("detail %q repeats the message", rec.Errors[0].Detail)
426+ }
427+}
@@ -13,6 +13,7 @@ import (
13 "mm/internal/config"13 "mm/internal/config"
14 "mm/internal/detector"14 "mm/internal/detector"
15 "mm/internal/session"15 "mm/internal/session"
16+ "mm/internal/store"
16 "mm/internal/ui"17 "mm/internal/ui"
17 18
18 "github.com/firebase/genkit/go/ai"19 "github.com/firebase/genkit/go/ai"
@@ -394,3 +395,33 @@ func TestRunnerTurnWithPaste(t *testing.T) {
394 t.Errorf("attachments echoed:\n%s", n)395 t.Errorf("attachments echoed:\n%s", n)
395 }396 }
396 }397 }
398+
399+// With sessions on, the red line of a failed turn is also written into the
400+// session file — outside the history, so the model never reads it — and a
401+// failed /resume is recorded too.
402+func TestRunnerFailureIsRecordedInTheSession(t *testing.T) {
403+ r, _ := newTestRunner(t, &fakeModel{fail: errors.New("boom")})
404+ config.Cfg.Sessions = config.SessionsConfig{Enabled: true, Dir: ".mm/sessions"}
405+ r.keep = store.NewKeeper(r.opts.Cwd)
406+ r.submit(promptOf("q"))
407+ waitIdle(t, r)
408+ r.resume("nope")
409+
410+ id := r.keep.CurrentID()
411+ if id == "" {
412+ t.Fatal("no session file opened")
413+ }
414+ rec, err := store.Open(r.keep.Dir()).Load(id)
415+ if err != nil {
416+ t.Fatal(err)
417+ }
418+ if len(rec.Messages) != 1 {
419+ t.Errorf("history in the file has %d messages, want the prompt alone", len(rec.Messages))
420+ }
421+ if len(rec.Errors) != 2 || rec.Errors[0].Where != store.FailGeneration || rec.Errors[0].Message != "explained: boom" || rec.Errors[0].Question != "q" || rec.Errors[0].Turn != 1 || rec.Errors[1].Where != store.FailResume {
422+ t.Errorf("errors recorded: %+v", rec.Errors)
423+ }
424+ if rec.Errors[0].Detail != "" {
425+ t.Errorf("detail %q repeats the message", rec.Errors[0].Detail)
426+ }
427+}
added space_invader.html +27 -0
new file mode 100644
@@ -0,0 +1,27 @@
1+<!DOCTYPE html>
2+<html lang="en">
3+<head>
4+ <meta charset="UTF-8">
5+ <title>Space Invader</title>
6+ <style>
7+ body {
8+ display: flex;
9+ justify-content: center;
10+ align-items: center;
11+ height: 100vh;
12+ margin: 0;
13+ background-color: #000;
14+ }
15+ .invader {
16+ width: 200px;
17+ height: 200px;
18+ background-image: url('data:image/svg+xml;utf8,\<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">\<rect width="200" height="200" fill="%23000"/>\<!-- Body -->\<rect x="60" y="40" width="80" height="60" fill="%23fff"/>\<!-- Eyes -->\<circle cx="80" cy="30" r="5" fill="%23000"/>\<circle cx="120" cy="30" r="5" fill="%23000"/>\<!-- Antenna -->\<line x1="100" y1="30" x2="100" y2="10" stroke="%23fff" stroke-width="4"/>\<!-- Mouth -->\<path d="M 90 70 Q 100 80 110 70" stroke="%23fff" stroke-width="4" fill="none"/>\</svg>');
19+ background-size: contain;
20+ background-repeat: no-repeat;
21+ }
22+ </style>
23+</head>
24+<body>
25+ <div class="invader"></div>
26+</body>
27+</html>
new file mode 100644
@@ -0,0 +1,27 @@
1+<!DOCTYPE html>
2+<html lang="en">
3+<head>
4+ <meta charset="UTF-8">
5+ <title>Space Invader</title>
6+ <style>
7+ body {
8+ display: flex;
9+ justify-content: center;
10+ align-items: center;
11+ height: 100vh;
12+ margin: 0;
13+ background-color: #000;
14+ }
15+ .invader {
16+ width: 200px;
17+ height: 200px;
18+ background-image: url('data:image/svg+xml;utf8,\<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">\<rect width="200" height="200" fill="%23000"/>\<!-- Body -->\<rect x="60" y="40" width="80" height="60" fill="%23fff"/>\<!-- Eyes -->\<circle cx="80" cy="30" r="5" fill="%23000"/>\<circle cx="120" cy="30" r="5" fill="%23000"/>\<!-- Antenna -->\<line x1="100" y1="30" x2="100" y2="10" stroke="%23fff" stroke-width="4"/>\<!-- Mouth -->\<path d="M 90 70 Q 100 80 110 70" stroke="%23fff" stroke-width="4" fill="none"/>\</svg>');
19+ background-size: contain;
20+ background-repeat: no-repeat;
21+ }
22+ </style>
23+</head>
24+<body>
25+ <div class="invader"></div>
26+</body>
27+</html>