aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-08-25-skills.md
blob: 79553f1747ec2331fea708fcd0f686b99a643d36 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
# Skills Support Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Let llamachat read instruction files from a skills directory (`~/.agents/skills/*/SKILL.md`), let the model load them on demand through a `load_skill` tool, and let the user load them by typing `/skill-name` or mentioning a skill name. A loaded skill's text stays in the system prompt for the rest of the session (chat mode) or just the reply (one-shot).

**Architecture:** A new `skills.py` module parses the SKILL.md files (frontmatter `name` + `description`, markdown body) with stdlib only. The existing tool loop in `backend.stream_chat`, currently hardcoded to `web_search`, is generalized to dispatch over `web_search` plus `load_skill` by call name. The UI appends loaded skill bodies to the system prompt, persists the loaded set on the session (chat mode only), and shows removable chips in the window. This mirrors the existing `prompts.py` / web-search patterns throughout.

**Tech Stack:** Python 3.11+ standard library (`re`, `json`, `sqlite3`, `tomllib`), PySide6 for the chips. No new dependencies.

**Spec:** `docs/superpowers/specs/2026-08-25-skills-design.md`

---

## Conventions for this codebase

Read this before Task 1. It is not optional context.

**Tests are not pytest.** `test_llamachat.py` is a single executable file of plain
functions. Each test ends with `print("ok  <short description>")`. Every test must
be registered by name in the `if __name__ == "__main__":` block at the bottom of the
file, in the order it should run. A test that is written but not registered never
runs, and the suite will still say "all checks passed".

Run the whole suite with:

```bash
./test_llamachat.py
```

There is no way to run a single test from the command line. To verify one test
fails or passes in isolation, run the suite and read that test's line. Adding a
temporary `if __name__` entry for just the new test is acceptable during a
red/green cycle but must be restored before committing.

**Every new source file needs the GPL header.** Copy it verbatim from the top of
`llamachat/config.py`, changing nothing but the docstring on the last line.

**Commits are GPG-signed automatically.** Do not pass `-c commit.gpgsign=false`.
Two git hooks scan for personal data and secrets; treat a rejection as correct.
Use `example.org`, fake keys, and generic paths in tests and fixtures. Never put a
real personal detail anywhere, including a test.

**Style:** comments explain why, not what. Deliberate simplifications get a
`# ponytail:` comment naming the ceiling and the upgrade path. Match the
surrounding code's density.

**Test fixture convention:** the tests below write fake skill files into a
`tempfile.TemporaryDirectory`. The `~/.agents/skills` directory on the developer's
machine is real and has personal skills in it; never assert on it in a test. Every
test creates its own skills directory.

---

## File Structure

**New files:**

| File | Responsibility |
| --- | --- |
| `llamachat/skills.py` | Parse `*/SKILL.md` files (frontmatter name/description + body), the `load_skill` tool schema, whole-word mention matching, `/name` command parsing, the `role: tool` message builders. No Qt, no HTTP. |

**Modified files:**

| File | Change |
| --- | --- |
| `llamachat/config.py` | Add `skills_enabled` / `skills_dir` defaults, `Config` fields, resolution (flag with no dir = off), generated config block. |
| `llamachat/db.py` | `sessions.skills` column (JSON array of names), migration, `set_skills()`. |
| `llamachat/backend.py` | `SkillsConfig`, generalize the tool loop to dispatch on call name, `_run_skill` handler. |
| `llamachat/ui.py` | Worker passes `skills_cfg` and emits `skill_loaded`; loaded-skills state + injection into the system prompt; `/name` + natural-mention loading in `send()`; chips bar; session restore/clear. |
| `test_llamachat.py` | New tests, each registered in the `__main__` block. |
| `README.md`, `CHANGELOG.md` | Document the feature. |

**Dependency direction:** `skills.py` depends on nothing in the project. `backend.py`
imports `skills` and `search`. `ui.py` imports `skills` and `backend`. Nothing imports
`ui`.

---

## Task 1: skills.py

**Files:**
- Create: `llamachat/skills.py`
- Test: `test_llamachat.py`

**Interfaces:**
- Produces: `Skill` dataclass (`name`, `description`, `text`); `SkillStore` class with `__init__(directory: Path)`, `names() -> list[str]`, `load(name) -> Skill | None`, `directory() -> str`, `tool_schema() -> dict`, `match_mentions(text) -> list[str]`; module functions `parse_commands(text, names) -> tuple[str, list[str]]`, `parse_name(arguments) -> str`, `tool_message(call_id, skill, error="", last=False) -> dict`; constants `LOAD_SKILL_NAME = "load_skill"`, `NO_MORE_TOOLS`, `SKILL_HEADER`.

- [ ] **Step 1: Write the failing tests**

Append these functions to `test_llamachat.py` (near the other skill tests will be fine, but placement does not matter since the file registers by name). Also add the fixture dict near the top, after `PRESETS_SAMPLE`:

```python
SKILLS_SAMPLE = {
    "finance-cli/SKILL.md": (
        "---\n"
        "name: finance-cli\n"
        "description: Operate a finance CLI from the command line.\n"
        "---\n"
        "\n"
        "# finance-cli\n"
        "Run `finance auth test` first.\n"
    ),
    "build-helper/SKILL.md": (
        "---\n"
        "name: build-helper\n"
        "description: Drive the sbo-dockerbuild test-build tool.\n"
        "---\n"
        "\n"
        "Load the skill, then test-build.\n"
    ),
    "no-frontmatter/SKILL.md": (
        "# no-frontmatter\n"
        "Skill with no frontmatter; the name falls back to the directory.\n"
    ),
}
```

```python
def _write_skills(root: Path, sample: dict) -> None:
    for rel, content in sample.items():
        path = root / rel
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(content)


def test_skills_store():
    from llamachat import skills

    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        _write_skills(root, SKILLS_SAMPLE)
        store = skills.SkillStore(root)

        assert store.names() == [
            "finance-cli", "no-frontmatter", "build-helper"
        ]

        skill = store.load("finance-cli")
        assert skill is not None
        assert skill.name == "finance-cli"
        assert skill.description == "Operate a finance CLI from the command line."
        assert skill.text.startswith("# finance-cli")

        # A name that does not exist, and an absent directory, both yield None.
        assert store.load("nope") is None
        assert skills.SkillStore(Path(tmp) / "missing").names() == []

        schema = store.tool_schema()
        function = schema["function"]
        assert function["name"] == "load_skill"
        assert function["parameters"]["required"] == ["name"]
        assert function["parameters"]["properties"]["name"]["enum"] == store.names()
        assert "finance-cli" in function["description"]

        directory = store.directory()
        assert "finance-cli" in directory
        assert "build-helper" in directory
    print("ok  skills store")


def test_skills_mentions():
    from llamachat import skills

    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        _write_skills(root, {"finance-cli/SKILL.md": SKILLS_SAMPLE["finance-cli/SKILL.md"]})
        store = skills.SkillStore(root)

        assert store.match_mentions("use finance-cli for this") == ["finance-cli"]
        assert store.match_mentions("USE FINANCE-CLI now") == ["finance-cli"]
        # No word boundary, no mention: a substring must not load a skill.
        assert store.match_mentions("finance-cli2 rocks") == []
        assert store.match_mentions("the fireflies are out") == []
        assert store.match_mentions("nothing here") == []
    print("ok  skills mentions")


def test_skills_parse_commands():
    from llamachat import skills

    known = {"finance-cli", "handoff"}
    text, loaded = skills.parse_commands(
        "/finance-cli fix my budget /unknown stays", known
    )
    assert loaded == ["finance-cli"]
    assert text == "fix my budget /unknown stays"

    text, loaded = skills.parse_commands("no commands here", known)
    assert loaded == []
    assert text == "no commands here"

    # A lone command leaves nothing behind.
    text, loaded = skills.parse_commands("/handoff", known)
    assert loaded == ["handoff"]
    assert text == ""

    # A name that is not a known skill is kept as plain text.
    text, loaded = skills.parse_commands("/nope", known)
    assert loaded == []
    assert text == "/nope"
    print("ok  skills parse commands")


def test_skill_tool_message():
    from llamachat import skills

    skill = skills.Skill(name="finance-cli", description="d", text="body text")
    msg = skills.tool_message("call_1", skill)
    assert msg["role"] == "tool"
    assert msg["tool_call_id"] == "call_1"
    assert "[skill: finance-cli]" in msg["content"]
    assert "body text" in msg["content"]

    err = skills.tool_message("call_1", None, error="No such skill")
    assert "error: No such skill" in err["content"]

    last = skills.tool_message("call_1", skill, last=True)
    assert skills.NO_MORE_TOOLS in last["content"]

    assert skills.parse_name('{"name": "finance-cli"}') == "finance-cli"
    assert skills.parse_name("not json") == ""
    assert skills.parse_name('{"query": "x"}') == ""
    assert skills.parse_name("") == ""
    print("ok  skill tool message")
```

- [ ] **Step 2: Run the suite to verify the new tests fail**

Run: `./test_llamachat.py`
Expected: `ImportError: cannot import name 'skills' from 'llamachat'` (the tests import the module that does not exist yet).

- [ ] **Step 3: Write `llamachat/skills.py`**

Create the file with the GPL header copied from `config.py` and this docstring: `"""Skills as instruction files, loaded into the model's context on demand.\n\nOne SKILL.md file per skill inside the skills directory. Each file starts\nwith a YAML frontmatter block carrying name and description, then the\ninstructions. Only those two keys are read; parsing is a line scan because\nadding a YAML dependency for two keys would be absurd.\n"""`

Module body:

```python
import json
import re
from dataclasses import dataclass
from pathlib import Path

LOAD_SKILL_NAME = "load_skill"

# The model is told which skill it just received.
SKILL_HEADER = "[skill: {name}]"

# Appended to the final round's tool result; see tool_message(). A note
# must ride on the result because a trailing system message is rejected by
# the chat template ("System message must be at the beginning").
NO_MORE_TOOLS = (
    "This was your last available tool use for this turn and tools are now "
    "unavailable. Write your answer now using the information above. Do not "
    "request another tool."
)

# A description is shortened to this for the model's directory, so listing
# every skill never costs a real chunk of context per request.
DIRECTORY_CHARS = 140


@dataclass
class Skill:
    name: str
    description: str
    text: str


def _parse(path: Path) -> Skill:
    """Read one SKILL.md, pulling name/description out of its frontmatter."""
    text = path.read_text(encoding="utf-8", errors="replace")
    name = path.parent.name
    description = ""
    body = text
    if text.startswith("---"):
        # split("---", 2) takes only the opening and closing fences, so a
        # body that itself contains "---" is left intact.
        parts = text.split("---", 2)
        if len(parts) == 3:
            body = parts[2]
            for line in parts[1].splitlines():
                key, sep, value = line.partition(":")
                if not sep:
                    continue
                key, value = key.strip(), value.strip()
                if key == "name" and value:
                    name = value
                elif key == "description":
                    description = value
    return Skill(name=name, description=description, text=body.strip())


def _short(text: str) -> str:
    """First line of a description, capped, so the directory stays compact."""
    stripped = text.strip()
    first = stripped.splitlines()[0] if stripped else ""
    return first[:DIRECTORY_CHARS]


class SkillStore:
    """The skills directory: one SKILL.md per skill, read only."""

    def __init__(self, directory: Path):
        self.dir = Path(directory)

    def _skills(self) -> list[Skill]:
        if not self.dir.is_dir():
            return []
        found = []
        for path in sorted(self.dir.glob("*/SKILL.md")):
            try:
                found.append(_parse(path))
            except OSError:
                continue  # a broken skill must not take down the chat
        return found

    def names(self) -> list[str]:
        return [skill.name for skill in self._skills()]

    def load(self, name: str) -> Skill | None:
        for skill in self._skills():
            if skill.name == name:
                return skill
        return None

    def directory(self) -> str:
        """Compact name-description lines for the model to choose from."""
        return "\n".join(
            f"- {s.name}: {_short(s.description)}" if s.description else f"- {s.name}"
            for s in self._skills()
        )

    def tool_schema(self) -> dict:
        """The OpenAI `load_skill` function schema for the model to call."""
        return {
            "type": "function",
            "function": {
                "name": LOAD_SKILL_NAME,
                "description": (
                    "Load a skill's full instructions into context. Use this "
                    "when the task matches one of these skills.\n\n"
                    f"Available skills:\n{self.directory()}"
                ),
                "parameters": {
                    "type": "object",
                    "properties": {
                        "name": {
                            "type": "string",
                            "description": "The skill to load.",
                            "enum": self.names(),
                        },
                    },
                    "required": ["name"],
                },
            },
        }

    def match_mentions(self, text: str) -> list[str]:
        """Skills whose names appear in `text` as whole words, in store order."""
        found = []
        for skill in self._skills():
            if re.search(r"\b" + re.escape(skill.name) + r"\b", text, re.IGNORECASE):
                found.append(skill.name)
        return found


def parse_commands(text: str, names: set[str]) -> tuple[str, list[str]]:
    """Split /name tokens off `text`; return (remaining, loaded names).

    Only tokens whose name is a known skill are treated as commands and
    stripped; any other /word survives as plain text.
    """
    loaded: list[str] = []
    remaining: list[str] = []
    for token in text.split():
        if token.startswith("/") and len(token) > 1:
            candidate = token[1:]
            if candidate in names:
                loaded.append(candidate)
                continue
        remaining.append(token)
    return " ".join(remaining), loaded


def parse_name(arguments: str) -> str:
    """The skill name out of a tool call's JSON arguments, or ''."""
    try:
        parsed = json.loads(arguments or "{}")
    except ValueError:
        return ""
    if not isinstance(parsed, dict):
        return ""
    name = parsed.get("name")
    return name.strip() if isinstance(name, str) else ""


def tool_message(
    call_id: str, skill: Skill | None, error: str = "", last: bool = False
) -> dict:
    """The `role: tool` message carrying a loaded skill back to the model.

    A failure is reported rather than swallowed, for the same reason as
    search.tool_message: the model asked for the tool and is waiting on it.
    With `last`, the message also says no further tools are available.
    """
    if error:
        content = f"error: {error}"
    elif skill is not None:
        content = f"{SKILL_HEADER.format(name=skill.name)}\n{skill.text}"
    else:
        content = "error: no skill"
    if last:
        content = f"{content}\n\n{NO_MORE_TOOLS}"
    return {
        "role": "tool",
        "tool_call_id": call_id,
        "content": content,
    }
```

- [ ] **Step 4: Register the four new tests and run the suite**

Add to the `if __name__ == "__main__":` block, before the final `print("\nall checks passed")`:

```python
    test_skills_store()
    test_skills_mentions()
    test_skills_parse_commands()
    test_skill_tool_message()
```

Run: `./test_llamachat.py`
Expected: `ok  skills store`, `ok  skills mentions`, `ok  skills parse commands`, `ok  skill tool message`, and `all checks passed`.

- [ ] **Step 5: Commit**

```bash
git add llamachat/skills.py test_llamachat.py
git commit -m "feat: skill store parsing for SKILL.md instruction files"
```

---

## Task 2: Config

**Files:**
- Modify: `llamachat/config.py` (DEFAULTS dict, `Config` dataclass, `load()`, `write_default()`)
- Test: `test_llamachat.py`

**Interfaces:**
- Consumes: nothing.
- Produces: `Config.skills_enabled: bool`, `Config.skills_dir: Path`; `config.DEFAULTS["skills_enabled"] is True`; `config.DEFAULTS["skills_dir"] == "~/.agents/skills"`.

- [ ] **Step 1: Write the failing test**

```python
def test_skills_config():
    with tempfile.TemporaryDirectory() as tmp:
        skills_dir = Path(tmp) / "skills"
        (skills_dir / "finance-cli").mkdir(parents=True)
        (skills_dir / "finance-cli" / "SKILL.md").write_text(
            "---\nname: finance-cli\ndescription: d\n---\n\nbody\n"
        )
        path = Path(tmp) / "config.toml"
        path.write_text(f'skills_enabled = true\nskills_dir = "{skills_dir}"\n')
        cfg = config.load(path)
        assert cfg.skills_enabled is True
        assert cfg.skills_dir == skills_dir

        # A flag whose directory does not exist resolves to off, mirroring
        # the search rule that a flag with no URL stays off.
        path.write_text('skills_enabled = true\nskills_dir = "/nonexistent"\n')
        assert config.load(path).skills_enabled is False

    assert config.DEFAULTS["skills_enabled"] is True
    assert config.DEFAULTS["skills_dir"] == "~/.agents/skills"

    with tempfile.TemporaryDirectory() as tmp:
        written = config.write_default(Path(tmp) / "config.toml")
        text = written.read_text()
        assert "skills_enabled" in text
        assert "skills_dir" in text
    print("ok  skills config")
```

- [ ] **Step 2: Run the suite to verify the test fails**

Run: `./test_llamachat.py`
Expected: `AttributeError: 'Config' object has no attribute 'skills_enabled'`.

- [ ] **Step 3: Modify `llamachat/config.py`**

In `DEFAULTS`, after the `max_searches` entry:

```python
    # Skills: instruction files read from a directory, one SKILL.md per
    # skill, loaded into the model's context on demand. On by default
    # because the directory already exists; a flag whose directory is
    # missing resolves to off.
    "skills_enabled": True,
    "skills_dir": "~/.agents/skills",
```

In the `Config` dataclass, after `max_searches: int`:

```python
    skills_enabled: bool
    skills_dir: Path
```

In `load()`, after the `search_enabled` resolution line:

```python
    skills_dir = Path(str(values["skills_dir"])).expanduser()
    skills_enabled = bool(values["skills_enabled"]) and skills_dir.is_dir()
```

In the `Config(...)` return, after `max_searches=int(values["max_searches"]),`:

```python
        skills_enabled=skills_enabled,
        skills_dir=skills_dir,
```

In `write_default()`, after the `max_searches` line and before the external-providers block:

```python
        '\n'
        '# Skills: instruction files read from a directory, one SKILL.md per\n'
        '# skill, loaded into the model\'s context on demand (by the model\n'
        '# itself, by /skill-name, or by mentioning a skill name). On by\n'
        '# default; a directory that does not exist resolves to off.\n'
        f'skills_enabled = {str(DEFAULTS["skills_enabled"]).lower()}\n'
        f'skills_dir = "{DEFAULTS["skills_dir"]}"\n'
```

- [ ] **Step 4: Register and run**

Add `test_skills_config()` to the `__main__` block. Run `./test_llamachat.py`.
Expected: `ok  skills config` and all prior checks still pass (check `test_config_defaults` in particular).

- [ ] **Step 5: Commit**

```bash
git add llamachat/config.py test_llamachat.py
git commit -m "feat: skills config keys, on by default"
```

---

## Task 3: Database

**Files:**
- Modify: `llamachat/db.py` (SCHEMA, `_migrate`, new `set_skills`, `import json`)
- Test: `test_llamachat.py`

**Interfaces:**
- Consumes: nothing.
- Produces: `History.set_skills(session_id: int, names: list[str]) -> None`; `sessions.skills` column (TEXT, JSON array, default `''`).

- [ ] **Step 1: Write the failing test**

```python
def test_skills_db():
    with tempfile.TemporaryDirectory() as tmp:
        history = db.History(Path(tmp) / "history.db")
        sid = history.create_session("chat", "m")
        history.set_skills(sid, ["finance-cli", "handoff"])
        session = history.get_session(sid)
        assert json.loads(session["skills"]) == ["finance-cli", "handoff"]

        history.set_skills(sid, [])
        assert json.loads(history.get_session(sid)["skills"]) == []

        # A fresh session has no skills stored.
        sid2 = history.create_session("oneshot", "m")
        assert history.get_session(sid2)["skills"] == ""
    print("ok  skills db")


def test_skills_column_migration():
    # A database created before the column existed gains it on open.
    import sqlite3 as _sqlite3

    with tempfile.TemporaryDirectory() as tmp:
        path = Path(tmp) / "old.db"
        conn = _sqlite3.connect(path)
        conn.execute(
            "CREATE TABLE sessions (id INTEGER PRIMARY KEY, mode TEXT NOT NULL,"
            " title TEXT, model TEXT, prompt_name TEXT NOT NULL DEFAULT '',"
            " prompt_custom TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL,"
            " updated_at INTEGER NOT NULL)"
        )
        conn.commit()
        conn.close()

        history = db.History(path)
        sid = history.create_session("chat", "m")
        history.set_skills(sid, ["finance-cli"])
        assert json.loads(history.get_session(sid)["skills"]) == ["finance-cli"]
    print("ok  skills column migration")
```

- [ ] **Step 2: Run the suite to verify the tests fail**

Run: `./test_llamachat.py`
Expected: `sqlite3.OperationalError: no such column: skills`.

- [ ] **Step 3: Modify `llamachat/db.py`**

Add `import json` to the imports (after `import sqlite3`).

In `SCHEMA`, in the `sessions` CREATE TABLE, after the `prompt_custom` line:

```sql
    skills        TEXT    NOT NULL DEFAULT '',
```

In `_migrate`, in the `"sessions"` dict, after `"prompt_custom": "TEXT NOT NULL DEFAULT ''",`:

```python
                "skills": "TEXT NOT NULL DEFAULT ''",
```

Add a method after `set_prompt`:

```python
    def set_skills(self, session_id: int, names: list[str]) -> None:
        """Record which skills a conversation has loaded, as a JSON array."""
        self.conn.execute(
            "UPDATE sessions SET skills = ? WHERE id = ?",
            (json.dumps(names), session_id),
        )
        self.conn.commit()
```

- [ ] **Step 4: Register and run**

Add both tests to the `__main__` block. Run `./test_llamachat.py`.
Expected: `ok  skills db`, `ok  skills column migration`, all prior checks pass.

- [ ] **Step 5: Commit**

```bash
git add llamachat/db.py test_llamachat.py
git commit -m "feat: persist loaded skills per session"
```

---

## Task 4: Generalize the backend tool loop

**Files:**
- Modify: `llamachat/backend.py` (`SkillsConfig`, `stream_chat`, new `_tool_schemas`, `_run_tool`, `_run_skill`, import `skills`)
- Test: `test_llamachat.py`

**Interfaces:**
- Consumes: `skills.SkillStore`, `skills.LOAD_SKILL_NAME`, `skills.parse_name`, `skills.tool_message`, `search.TOOL_SCHEMA`.
- Produces: `backend.SkillsConfig` dataclass (`enabled: bool = False`, `store: skills.SkillStore | None = None`, `max_searches: int = 2`); `stream_chat(model, messages, search_cfg=None, skills_cfg=None)`; new stream kind `("skill_loaded", name)`.

- [ ] **Step 1: Write the failing tests**

```python
def _skill_round(name: str = "finance-cli"):
    """One streamed round that asks to load a skill."""
    return [
        (
            "tool_calls",
            json.dumps(
                [
                    {
                        "index": 0,
                        "id": "call_2",
                        "function": {
                            "name": "load_skill",
                            "arguments": json.dumps({"name": name}),
                        },
                    }
                ]
            ),
        ),
        ("tool_finish", ""),
    ]


def test_skill_tool_round():
    from llamachat import skills

    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        _write_skills(root, {"finance-cli/SKILL.md": SKILLS_SAMPLE["finance-cli/SKILL.md"]})
        store = skills.SkillStore(root)

        client = backend.Client("http://x")
        seen_messages = []
        sent_tools = []

        def scripted(model, messages, tools):
            sent_tools.append(tools)
            seen_messages.append([dict(m) for m in messages])
            if len(seen_messages) == 1:
                yield from _skill_round()
            else:
                yield ("content", "answered")

        client._stream_once = scripted
        cfg = backend.SkillsConfig(enabled=True, store=store, max_searches=1)
        out = list(client.stream_chat(
            "m", [{"role": "user", "content": "hi"}], skills_cfg=cfg
        ))

        # The load is reported and the answer flows through.
        assert ("skill_loaded", "finance-cli") in out, out
        assert ("content", "answered") in out, out

        # The offered schema is the load_skill one; the final round has none.
        assert sent_tools[0] == [store.tool_schema()], sent_tools[0]
        assert sent_tools[-1] is None, sent_tools

        # The second request carries the assistant tool call and a tool
        # reply with the skill body.
        second = seen_messages[1]
        assistant = next(
            m for m in second if m["role"] == "assistant" and m.get("tool_calls")
        )
        assert assistant["tool_calls"][0]["function"]["name"] == "load_skill"
        tool_msg = next(m for m in second if m["role"] == "tool")
        assert tool_msg["tool_call_id"] == "call_2"
        assert "run `finance auth test` first" in tool_msg["content"].lower()
        assert "[skill: finance-cli]" in tool_msg["content"]
    print("ok  skill tool round")


def test_skill_tool_errors():
    from llamachat import skills

    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        _write_skills(root, {"finance-cli/SKILL.md": SKILLS_SAMPLE["finance-cli/SKILL.md"]})
        store = skills.SkillStore(root)
        client = backend.Client("http://x")
        cfg = backend.SkillsConfig(enabled=True, store=store, max_searches=1)

        # Unknown skill name -> error tool message, no skill_loaded yield.
        rounds = [_skill_round("notes-keeper"), [("content", "done")]]
        seen = []

        def scripted(model, messages, tools):
            seen.append(tools)
            yield from rounds[min(len(seen) - 1, len(rounds) - 1)]

        client._stream_once = scripted
        out = list(client.stream_chat(
            "m", [{"role": "user", "content": "hi"}], skills_cfg=cfg
        ))
        assert ("content", "done") in out, out
        assert not any(k == "skill_loaded" for k, _ in out), out
        tool_msg = next(m for m in seen[1] if m["role"] == "tool")
        assert "No such skill: notes-keeper" in tool_msg["content"]

        # Malformed arguments are answered rather than left dangling.
        bad = {"id": "call_9", "name": "load_skill", "arguments": "{not json"}
        convo: list = []
        emitted = list(client._run_skill(convo, bad, store))
        assert emitted == [], emitted
        assert convo[-1]["role"] == "tool"
        assert "error" in convo[-1]["content"]

        # A tool call naming a tool that does not exist is answered too.
        rounds2 = [[
            ("tool_calls", json.dumps([{
                "index": 0, "id": "call_7",
                "function": {"name": "no_such_tool", "arguments": "{}"},
            }])),
            ("tool_finish", ""),
        ], [("content", "done")]]
        seen2 = []

        def scripted2(model, messages, tools):
            seen2.append(tools)
            yield from rounds2[min(len(seen2) - 1, len(rounds2) - 1)]

        client._stream_once = scripted2
        out2 = list(client.stream_chat(
            "m", [{"role": "user", "content": "hi"}], skills_cfg=cfg
        ))
        assert ("content", "done") in out2, out2
        unknown = next(m for m in seen2[1] if m["role"] == "tool")
        assert "no_such_tool" in unknown["content"]
    print("ok  skill tool errors")


def test_skill_round_cap():
    from llamachat import skills

    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        _write_skills(root, {"finance-cli/SKILL.md": SKILLS_SAMPLE["finance-cli/SKILL.md"]})
        store = skills.SkillStore(root)

        client = backend.Client("http://x")
        sent_tools = []

        def always_skill_calls(model, messages, tools):
            sent_tools.append(tools)
            yield from _skill_round()

        client._stream_once = always_skill_calls
        cfg = backend.SkillsConfig(enabled=True, store=store, max_searches=1)
        list(client.stream_chat(
            "m", [{"role": "user", "content": "hi"}], skills_cfg=cfg
        ))

        # max_searches rounds offer the tool, then a final round without it,
        # even when search is entirely disabled (skills-only loop path).
        assert len(sent_tools) == 2, sent_tools
        assert sent_tools[0] == [store.tool_schema()], sent_tools[0]
        assert sent_tools[-1] is None, "the final round must withdraw the tool"
    print("ok  skill round cap")
```

- [ ] **Step 2: Run the suite to verify the tests fail**

Run: `./test_llamachat.py`
Expected: `AttributeError: type object 'backend.Client' has no attribute 'SkillsConfig'`.

- [ ] **Step 3: Modify `llamachat/backend.py`**

Add the import (alphabetical, after `from . import providers as providers_mod`):

```python
from . import skills
```

Add after the `SearchConfig` dataclass:

```python
@dataclass
class SkillsConfig:
    """What stream_chat needs to offer and run skill loading."""
    enabled: bool = False
    store: skills.SkillStore | None = None
    max_searches: int = 2  # tool rounds per turn, same cap as search
```

Replace `stream_chat` with:

```python
    def stream_chat(
        self,
        model: str,
        messages: list[dict],
        search_cfg: "SearchConfig | None" = None,
        skills_cfg: "SkillsConfig | None" = None,
    ):
        """Yield (kind, text) pairs as the model produces them.

        `kind` is 'reasoning' for the model's thinking and 'content' for the
        reply proper. Presets with a reasoning budget emit the former in a
        separate `reasoning_content` delta field, which is what lets the UI
        keep the two apart.

        With `search_cfg`, the model is offered a `web_search` tool; with
        `skills_cfg`, a `load_skill` tool. Either turns this into a loop: a
        round that ends in a tool call is handled, the result appended, and
        the request re-sent. Two further kinds are yielded around each search,
        'search_start' and 'search_done', and one around each loaded skill,
        'skill_loaded'. After the round cap the tools are withdrawn, which
        forces an answer.

        Loading a different model makes the router unload the previous one,
        so the first chunk can take several seconds. That wait happens
        inside the initial `stream()` call.
        """
        search_on = search_cfg is not None and search_cfg.enabled
        skills_on = skills_cfg is not None and skills_cfg.enabled
        if not (search_on or skills_on):
            yield from self._stream_once(model, messages, tools=None)
            return

        # The loop mutates its own copy; the caller's list is history.
        convo = list(messages)
        cap = search_cfg.max_searches if search_on else skills_cfg.max_searches
        for round_number in range(cap + 1):
            last_round = round_number == cap
            tools = None if last_round else self._tool_schemas(search_cfg, skills_cfg)
            calls: dict = {}
            saw_tool_finish = False
            reasoning = ""

            for kind, piece in self._stream_once(model, convo, tools=tools):
                if kind == "tool_calls":
                    search.accumulate(calls, json.loads(piece))
                elif kind == "tool_finish":
                    saw_tool_finish = True
                else:
                    if kind == "reasoning":
                        reasoning += piece
                    yield (kind, piece)

            wanted = [call for call in calls.values() if call.get("name")]
            if not (saw_tool_finish and wanted) or last_round:
                return

            assistant = {
                "role": "assistant",
                "content": None,
                "tool_calls": [
                    {
                        "id": call["id"],
                        "type": "function",
                        "function": {
                            "name": call["name"],
                            "arguments": call["arguments"],
                        },
                    }
                    for call in wanted
                ],
            }
            # Interleaved-thinking models (DeepSeek V3.2+/V4, GLM-4.7+ on
            # SiliconFlow) emit their chain-of-thought as reasoning_content and
            # require it replayed verbatim on the assistant tool-call message.
            # Dropping it breaks their multi-step tool flow: the follow-up
            # round ends with the model thinking and no answer. Local reasoning
            # models tolerate the extra key, so include it whenever it exists.
            if reasoning:
                assistant["reasoning_content"] = reasoning
            convo.append(assistant)
            # The round after this one is the last, so its results are the
            # last the model will get. Saying so is what makes it answer
            # instead of asking for a tool it can no longer use.
            final = round_number + 1 == cap
            for call in wanted:
                yield from self._run_tool(
                    convo, call, search_cfg, skills_cfg, final
                )
```

Add these methods to `Client` (after `_run_search`, before `_stream_once`):

```python
    def _tool_schemas(self, search_cfg, skills_cfg):
        """The tools to offer this round, or None when there are none."""
        tools = []
        if search_cfg is not None and search_cfg.enabled:
            tools.append(search.TOOL_SCHEMA)
        if (
            skills_cfg is not None and skills_cfg.enabled
            and skills_cfg.store is not None
        ):
            tools.append(skills_cfg.store.tool_schema())
        return tools or None

    def _run_tool(self, convo, call, search_cfg, skills_cfg, last=False):
        """Route one tool call to its handler, or answer that it is unknown."""
        name = call["name"]
        if (
            search_cfg is not None and search_cfg.enabled
            and name == search.TOOL_SCHEMA["function"]["name"]
        ):
            yield from self._run_search(convo, call, search_cfg, last)
            return
        if (
            skills_cfg is not None and skills_cfg.enabled
            and skills_cfg.store is not None
            and name == skills.LOAD_SKILL_NAME
        ):
            yield from self._run_skill(convo, call, skills_cfg.store, last)
            return
        # A tool call for a name that exists nowhere still needs an answer,
        # or the next request is rejected for an unanswered call.
        convo.append(
            {
                "role": "tool",
                "tool_call_id": call["id"],
                "content": f"error: unknown tool '{name}'",
            }
        )

    def _run_skill(self, convo, call, store, last=False):
        """Load a skill's text and append it to `convo`.

        Every failure still appends a tool message, so the turn completes
        either way. Success additionally yields ("skill_loaded", name) so
        the UI can persist the load for later turns.
        """
        name = skills.parse_name(call["arguments"])
        if not name:
            convo.append(
                skills.tool_message(
                    call["id"], None, error="Malformed skill request", last=last
                )
            )
            return
        skill = store.load(name)
        if skill is None:
            convo.append(
                skills.tool_message(
                    call["id"], None, error=f"No such skill: {name}", last=last
                )
            )
            return
        convo.append(skills.tool_message(call["id"], skill, last=last))
        yield ("skill_loaded", name)
```

- [ ] **Step 4: Register and run**

Add the three tests to the `__main__` block. Run `./test_llamachat.py`.
Expected: `ok  skill tool round`, `ok  skill tool errors`, `ok  skill round cap`, and every pre-existing search-loop test (`test_search_loop_cap`, `test_search_failure_paths`, `test_reasoning_content_preserved`, `test_tool_call_finish_with_usage`, `test_final_round_note`) still passes. The search loop behavior is unchanged: search-only still offers exactly `[search.TOOL_SCHEMA]` and caps at `max_searches`.

- [ ] **Step 5: Commit**

```bash
git add llamachat/backend.py test_llamachat.py
git commit -m "feat: dispatch the tool loop over web_search and load_skill"
```

---

## Task 5: UI — state, injection, user path

**Files:**
- Modify: `llamachat/ui.py`
- Test: `test_llamachat.py`

**Interfaces:**
- Consumes: `skills.SkillStore`, `skills.parse_commands`, `skills.match_mentions`, `backend.SkillsConfig`.
- Produces: module-level helpers `_skill_parts(store, loaded: list[str]) -> list[str]` and `_skills_list(raw: str) -> list[str]`; `ChatWindow` gains `self.skills` (a `SkillStore`), `self.loaded_skills: list[str]`, and methods `_load_skill`, `_unload_skill`, `_persist_skills`, `_load_skills_from_text`, `_skills_config`, `_refresh_skill_chips`.

- [ ] **Step 1: Write the failing test**

```python
def test_skill_parts():
    from llamachat import skills
    from llamachat.ui import _skill_parts, _skills_list

    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        _write_skills(root, {"finance-cli/SKILL.md": SKILLS_SAMPLE["finance-cli/SKILL.md"]})
        store = skills.SkillStore(root)

        parts = _skill_parts(store, ["finance-cli"])
        assert len(parts) == 1, parts
        assert "[skill: finance-cli]" in parts[0]
        assert "finance auth test" in parts[0]

        # A name that no longer exists contributes nothing, and the parts
        # keep load order.
        parts2 = _skill_parts(store, ["gone", "finance-cli"])
        assert parts2 == ["[skill: finance-cli]\nRun `finance auth test` first."]

    assert _skills_list('["a", "b"]') == ["a", "b"]
    assert _skills_list("") == []
    assert _skills_list("not json") == []
    assert _skills_list("[1, 2]") == []
    print("ok  skill parts")
```

- [ ] **Step 2: Run the suite to verify the test fails**

Run: `./test_llamachat.py`
Expected: `ImportError: cannot import name '_skill_parts' from 'llamachat.ui'`.

- [ ] **Step 3: Modify `llamachat/ui.py`**

Add `skills` to the project import on the `from . import backend, ...` line:

```python
from . import backend, models as models_mod, prompts, skills
```

Add these module-level helpers near the other module helpers (e.g. next to `_date_note` at the bottom of the file):

```python
def _skill_parts(store, loaded: list[str]) -> list[str]:
    """The system-prompt chunks for the loaded skills, in load order."""
    parts = []
    for name in loaded:
        skill = store.load(name)
        if skill is not None:
            parts.append(f"[skill: {skill.name}]\n{skill.text}")
    return parts


def _skills_list(raw: str) -> list[str]:
    """Session-stored skills JSON back to a list, tolerating absence/garbage."""
    try:
        parsed = json.loads(raw) if raw else []
    except ValueError:
        return []
    if not isinstance(parsed, list):
        return []
    return [name for name in parsed if isinstance(name, str)]
```

In `__init__`, after the `self.prompts.ensure_default()` line:

```python
        self.skills = skills.SkillStore(cfg.skills_dir)
        self.loaded_skills: list[str] = []
```

Change `_system_messages()` to:

```python
    def _system_messages(self) -> list[dict]:
        parts = [self.system_prompt_text()]
        if self.cfg.search_enabled:
            parts.append(_date_note())
        parts.extend(_skill_parts(self.skills, self.loaded_skills))
        text = "\n\n".join(p for p in parts if p)
        return [{"role": "system", "content": text}] if text else []
```

Add a `# -- skills ---` section with these methods (place after `_system_messages`, before `update_meter`):

```python
    def _load_skill(self, name: str) -> bool:
        """Record a skill as loaded; True when it was not already present.

        Persisted to the session in chat mode so reopening the conversation
        restores it. One-shot mode keeps the set in memory only; it is
        cleared when the reply finishes.
        """
        if not self.cfg.skills_enabled or name in self.loaded_skills:
            return False
        if self.skills.load(name) is None:
            return False
        self.loaded_skills.append(name)
        self._persist_skills()
        self._refresh_skill_chips()
        return True

    def _unload_skill(self, name: str) -> None:
        if name not in self.loaded_skills:
            return
        self.loaded_skills.remove(name)
        self._persist_skills()
        self._refresh_skill_chips()

    def _persist_skills(self) -> None:
        if (
            self.cfg.skills_enabled
            and self.mode == MODE_CHAT
            and self.session_id is not None
        ):
            self.history.set_skills(self.session_id, self.loaded_skills)

    def _load_skills_from_text(self, text: str) -> str:
        """Load skills named in the draft; returns the text to actually send.

        /name tokens load and are stripped. A skill name appearing as a
        whole word loads and stays, since it reads naturally either way.
        """
        if not self.cfg.skills_enabled:
            return text
        known = set(self.skills.names())
        text, command_names = skills.parse_commands(text, known)
        for name in command_names:
            self._load_skill(name)
        for name in self.skills.match_mentions(text):
            self._load_skill(name)
        return text

    def _skills_config(self) -> backend.SkillsConfig | None:
        """What the stream worker needs to offer the load_skill tool."""
        if not self.cfg.skills_enabled or not self.skills.names():
            return None
        return backend.SkillsConfig(
            enabled=True, store=self.skills, max_searches=self.cfg.max_searches
        )

    def _refresh_skill_chips(self) -> None:
        """Rebuild the loaded-skill chips row; hidden when nothing is loaded.

        The chips bar is built by `_build_ui`, so this is a no-op until then
        (it is first called from `new_session` during construction).
        """
        if not hasattr(self, "skills_bar"):
            return  # still building the window
        for chip in self.skill_chips.values():
            chip.deleteLater()
        self.skill_chips.clear()
        for name in self.loaded_skills:
            chip = QPushButton(f"✕ {name}")
            chip.setFlat(True)
            chip.setToolTip(f"Loaded skill. Click to unload {name}.")
            chip.clicked.connect(
                lambda _checked=False, n=name: self._unload_skill(n)
            )
            self.skills_layout.addWidget(chip)
            self.skill_chips[name] = chip
        self.skills_bar.setVisible(bool(self.loaded_skills))
```

In `send()`, after the existing `if not text and not self.attachments: return` guard and the model check, insert the skill scan and the stripped-empty re-guard:

```python
        text = self._load_skills_from_text(text)
        if not text and not self.attachments:
            return
```

In `send()`, after the `if self.session_id is None:` block that creates the session, add:

```python
        self._persist_skills()
```

In `new_session()`, add these two lines (anywhere in the body, e.g. after `self.searches = []`):

```python
        self.loaded_skills = []
        self._refresh_skill_chips()
```

- [ ] **Step 4: Register and run**

Add `test_skill_parts()` to the `__main__` block. Run `./test_llamachat.py`.
Expected: `ok  skill parts` and all prior checks pass. The GUI tests that construct a `ChatWindow` must still construct, so if `_refresh_skill_chips` is called before the chips bar exists (it is, from `new_session` during construction), it must guard on `hasattr`. Step 5 adds that guard; if the suite fails on construction here, proceed to Step 5 and re-run.

- [ ] **Step 5: Commit**

```bash
git add llamachat/ui.py test_llamachat.py
git commit -m "feat: inject loaded skills into the system prompt and load from text"
```

---

## Task 6: UI — worker plumbing and chips bar

**Files:**
- Modify: `llamachat/ui.py`
- Test: `test_llamachat.py` (regression only)

**Interfaces:**
- Consumes: `ChatWindow._load_skill`, `ChatWindow._unload_skill`, `ChatWindow._skills_config`, `self.loaded_skills`.
- Produces: `StreamWorker.skill_loaded = Signal(str)`; `StreamWorker.__init__(client, model, messages, search_cfg=None, skills_cfg=None)`; `ChatWindow.skills_bar` (a `QWidget`), `ChatWindow.skill_chips` (dict), `ChatWindow._refresh_skill_chips`, `ChatWindow._on_skill_loaded`.

- [ ] **Step 1: Verify the current suite passes**

Run: `./test_llamachat.py`
Expected: `all checks passed`.

- [ ] **Step 2: Modify `llamachat/ui.py`**

In `StreamWorker`, add a signal next to `search_done`:

```python
    skill_loaded = Signal(str)
```

Change the `StreamWorker.__init__` signature to:

```python
    def __init__(
        self,
        client,
        model: str,
        messages: list[dict],
        search_cfg: SearchConfig | None = None,
        skills_cfg: backend.SkillsConfig | None = None,
    ):
```

and add the assignment `self.skills_cfg = skills_cfg` after `self.search_cfg = search_cfg`.

In `StreamWorker.run()`, change the `stream_chat` call to pass the skills config:

```python
            stream = client.stream_chat(
                wire, self.messages, self.search_cfg, self.skills_cfg
            )
```

In the same loop, add a branch for the new kind, after the `search_done` branch:

```python
                elif kind == "skill_loaded":
                    self.skill_loaded.emit(piece)
```

In `_build_ui`, after `outer.addLayout(top)`:

```python
        # Loaded-skills chips, hidden unless at least one skill is loaded.
        self.skills_bar = QWidget()
        skills_layout = QHBoxLayout(self.skills_bar)
        skills_layout.setContentsMargins(8, 0, 8, 0)
        skills_layout.setSpacing(6)
        self.skill_chips: dict[str, QPushButton] = {}
        self.skills_bar.setVisible(False)
        outer.addWidget(self.skills_bar)
```

In `_start_stream`, change the worker construction to:

```python
        self.worker = StreamWorker(
            self.client, model, messages,
            self._search_config(), self._skills_config(),
        )
```

and add a connect after the `search_done` connect:

```python
        self.worker.skill_loaded.connect(self._on_skill_loaded)
```

Add the handler (place in the skills section from Task 5; `_refresh_skill_chips` and `_load_skill` already live there):

```python
    @Slot(str)
    def _on_skill_loaded(self, name: str) -> None:
        """A skill the model just loaded via its tool, recorded for later."""
        if self._load_skill(name):
            self.show_status(f"Loaded skill: {name}")
```

In `_on_stream_finished`, at the top of the method, drop one-shot loaded skills:

```python
        # One-shot mode: a skill loaded mid-reply is gone when the reply ends.
        if self.mode == MODE_ONESHOT:
            self.loaded_skills = []
            self._refresh_skill_chips()
```

In `open_session`, after the prompt-restore block, restore the session's skills:

```python
        self.loaded_skills = _skills_list(_column(session, "skills"))
        self._refresh_skill_chips()
```

- [ ] **Step 3: Run the suite**

Run: `./test_llamachat.py`
Expected: `all checks passed`. Also run a quick import sanity check that the window constructs with skills enabled:

```bash
python3 -c "import sys; sys.path.insert(0, '.'); from llamachat import config, db, ui; cfg = config.load(); h = db.History('/tmp/skills-test.db'); print('ok window imports')"
```

Expected: `ok window imports` (this also exercises `_build_ui` indirectly only if it constructed a window; a failed import is the real signal here).

- [ ] **Step 4: Commit**

```bash
git add llamachat/ui.py
git commit -m "feat: stream skill_loaded events and show loaded-skill chips"
```

---

## Task 7: Docs

**Files:**
- Modify: `README.md`, `CHANGELOG.md`

**Interfaces:**
- Consumes: nothing.

- [ ] **Step 1: Update README**

1. In the Features list, after the **Web search** bullet, add:

```markdown
- **Skills.** Instruction files read from `~/.agents/skills/*/SKILL.md`,
  loaded into the model's context on demand. The model can call a
  `load_skill` tool when a task matches one; you can type `/skill-name` or
  mention a skill's name. Loaded skills show as removable chips, stay for
  the whole session in chat mode, and drop after the reply in one-shot mode.
```

2. In the **Configuration** section, extend the config example with:

```toml
# Skills. Instruction files in ~/.agents/skills/*/SKILL.md, loaded into the
# model's context on demand. On by default; a directory that does not exist
# stays off.
skills_enabled = true
skills_dir = "~/.agents/skills"
```

3. Add a **### Skills** section after the **### Web search** section, describing: the format (`<name>/SKILL.md`, frontmatter `name` + `description`), the three load paths (model tool, `/name`, natural mention), the persistence rule (chat = session, one-shot = reply only), and that `max_searches` also caps skill-loading rounds.

- [ ] **Step 2: Update CHANGELOG**

In `## [Unreleased]``### Added`, add a bullet:

```markdown
- Skills. Instruction files read from `~/.agents/skills/*/SKILL.md` can be
  loaded into the model's context on demand: the model calls a `load_skill`
  tool, or you type `/skill-name` or mention a skill's name. Loaded skills
  stay for the whole session in chat mode, drop after the reply in one-shot
  mode, and show as removable chips. The backend tool loop now dispatches
  between `web_search` and `load_skill`; `max_searches` caps total tool
  rounds per turn.
```

- [ ] **Step 3: Verify no personal data**

Run: `git diff -- README.md CHANGELOG.md | git hooks` is not a real command; instead inspect the diff and make sure no real account names, email addresses (other than the license line), or personal identifiers were added. The commit hooks will also enforce this.

- [ ] **Step 4: Commit**

```bash
git add README.md CHANGELOG.md
git commit -m "docs: document skills support"
```

---

## Task 8: Final verification

**Files:** none.

- [ ] **Step 1: Full suite**

Run: `./test_llamachat.py`
Expected: `all checks passed`, including every pre-existing check. The test count reported at the end must include the new `ok` lines: `ok  skills store`, `ok  skills mentions`, `ok  skills parse commands`, `ok  skill tool message`, `ok  skills config`, `ok  skills db`, `ok  skills column migration`, `ok  skill tool round`, `ok  skill tool errors`, `ok  skill round cap`, `ok  skill parts`.

- [ ] **Step 2: Manual smoke**

Launch the daemon against the real router (or confirm it is already running) and exercise: mention a skill name in a message in chat mode and confirm the chip appears and the reply carries the skill; click the chip to unload; verify reopening the session restores the chips in chat mode and that one-shot replies do not carry skills into the next question.

- [ ] **Step 3: Commit any follow-up fixes**

If the smoke test surfaced a fix, commit it separately with a `fix:` message.