Compare commits
217 Commits
720eba8afb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 55ed1bf5a9 | |||
| ad2e6f5f4a | |||
| 2738061b5d | |||
| ffc9b331ea | |||
| 729c8fdd5f | |||
| e56b710344 | |||
| d782308b42 | |||
| 8e8752e519 | |||
| 58f6b108c3 | |||
| cb26a044d3 | |||
| af643c452d | |||
| 1e80216b07 | |||
| eb67944e68 | |||
| 7bc0d0772c | |||
| ede3c0583f | |||
| aafc68e911 | |||
| 8c85c00ae9 | |||
| c5828e0591 | |||
| 5fd8de031d | |||
| de99b4a89e | |||
| f5d799c64b | |||
| e143495d6c | |||
| 3c4eaec76b | |||
| 007ba81c02 | |||
| 3977ed6822 | |||
| 289ea1344c | |||
| 63da676b5a | |||
| 8fc816bcd9 | |||
| 585d4919e7 | |||
| 6544d3d8a0 | |||
| 2fed687a00 | |||
| 36d7a53029 | |||
| 6422bb96f2 | |||
| 9ed4809837 | |||
| 6945c29a47 | |||
| 521e4c7fb6 | |||
| b8bb26da75 | |||
| ef1eebda58 | |||
| 06e327ed85 | |||
| adcb7bfd77 | |||
| 2b957c7f09 | |||
| 7a97ee4e31 | |||
| 3e3975c724 | |||
| f218e47814 | |||
| 53a070a59a | |||
| 969737b6b5 | |||
| 0b7d409fbc | |||
| d637d3e7f6 | |||
| ab9b9e9f8f | |||
| 78ab95922f | |||
| 7edc1ce94b | |||
| e6ea8a0060 | |||
| 83106239d4 | |||
| e05adef7b7 | |||
| e7d7eb14e1 | |||
| 8cd59cb292 | |||
| 582800d548 | |||
| 8885ffae67 | |||
| 40ae623094 | |||
| 7409e4e6a0 | |||
| 2cb47ef2cf | |||
| 48a81bb8a2 | |||
| d2fbf78927 | |||
| eca87c74a6 | |||
| bb9a92b25b | |||
| 68e0aafbbb | |||
| f967bc47bc | |||
| 75373ebdc1 | |||
| ac095ba0e4 | |||
| 7f7fc327fd | |||
| bd32795e23 | |||
| 6484f9466c | |||
| e42101e08b | |||
| 09eb9a983d | |||
| e24f808b63 | |||
| 3e64bd0b7e | |||
| ee672d2479 | |||
| 039802b9e2 | |||
| 6f6f0178b1 | |||
| e684ac481e | |||
| 02bb345fd7 | |||
| a3e3e80c83 | |||
| 23b1a30c2a | |||
| ac96639c20 | |||
| ab89ba8391 | |||
| 0e92de7eea | |||
| 111b985d7d | |||
| 37ddf4342a | |||
| 708a3ee963 | |||
| 9963a0dbe4 | |||
| 210a260735 | |||
| 005928ef88 | |||
| 900cb6b8e8 | |||
| 7d9a0dae36 | |||
| aca09d87a1 | |||
| 10e68957b1 | |||
| 5a7593a793 | |||
| ba881998bc | |||
| b21b4ff77e | |||
| 6d1afa41ed | |||
| 27a28b1ae7 | |||
| 5ca717ca4a | |||
| c0ec330024 | |||
| b9749dd6a2 | |||
| 4b6fbfe732 | |||
| f552269cb3 | |||
| fc3380f8ce | |||
| 6f34c95c51 | |||
| 908d7719fb | |||
| 310ff13c77 | |||
| e49fb929d8 | |||
| 78081143cf | |||
| 6b5729b98a | |||
| f18b40091c | |||
| 4ecb86052b | |||
| 62eb6286b4 | |||
| 75c5745cbd | |||
| c9fc17ecf5 | |||
| 0d685ce277 | |||
| c63c3079be | |||
| dcc03155a2 | |||
| 11d7cb053c | |||
| ec40892560 | |||
| a6ada9dac3 | |||
| 42ff195556 | |||
| 60738f65c2 | |||
| 61ce9cc1be | |||
| ce103a7353 | |||
| 7711b841dd | |||
| 66582b498c | |||
| 54c191f801 | |||
| d0bfc7b21b | |||
| e5d8f69f16 | |||
| 42c602b814 | |||
| 431f75aad7 | |||
| 488bb15683 | |||
| 13e5a9bb56 | |||
| 5b3ebdccf3 | |||
| 8c85c02a7d | |||
| f94aaba6c1 | |||
| 25049c8aef | |||
| 4c0d36324c | |||
| f80ea948ea | |||
| 5e254857b9 | |||
| 7cd9370366 | |||
| 066bf467ba | |||
| 7809e079e3 | |||
| bdb6f01934 | |||
| 064971f601 | |||
| 41709bb977 | |||
| 77e853ab49 | |||
| 6a10f760cf | |||
| 91d0a7d8e4 | |||
| 7810425849 | |||
| b7363ed7e1 | |||
| 505002bb2b | |||
| 6f9309d329 | |||
| 98fecc314f | |||
| 80d1587189 | |||
| 7e53ecd946 | |||
| 2d6eb929d7 | |||
| f54d0460ad | |||
| 95869fb11f | |||
| 5a89d4addb | |||
| b91e434c87 | |||
| 16f29bd64a | |||
| 9eb0fe797a | |||
| 8346d63e64 | |||
| 20f5b33e00 | |||
| 194d64dacd | |||
| 8f83c3ca8c | |||
| 053c4de529 | |||
| bfc9e6f75b | |||
| da30375413 | |||
| cfe8d4ff9f | |||
| 25e12f79de | |||
| 9b36cfadd6 | |||
| d5b67947f9 | |||
| 42491d3dd0 | |||
| a9cc1dc309 | |||
| 1020865026 | |||
| b4650c03b8 | |||
| 8d3915ad0e | |||
| 0046a87130 | |||
| 1ca5033bc8 | |||
| aced50f67f | |||
| 08ee0a2f59 | |||
| eee7c8190d | |||
| 8c35889884 | |||
| 62109e993a | |||
| 14f8618c0a | |||
| b8e91ee042 | |||
| d0364d5d9e | |||
| 00edf973fd | |||
| 4af1e967ed | |||
| 2f08adbfb7 | |||
| 3732ccd4d8 | |||
| 1fd8e7e773 | |||
| 540928f24f | |||
| 864d643da9 | |||
| f15713d183 | |||
| 537724989a | |||
| ec43fb2e14 | |||
| 8d6ec10b74 | |||
| 00da6f1466 | |||
| 8570826927 | |||
| 272866c7e5 | |||
| f7b9f1b251 | |||
| c28029681d | |||
| ae4ec34822 | |||
| 0614e0ebe6 | |||
| 8ce46a7b98 | |||
| 0d6f0d2fc9 | |||
| b233fe41e7 | |||
| 47241b8421 | |||
| 6f6f4204ee | |||
| e606a09d2c |
@@ -1,34 +0,0 @@
|
|||||||
# Alexion's Agent Instructions
|
|
||||||
|
|
||||||
These are common instructions for Alexion's agents across all scenarios.
|
|
||||||
|
|
||||||
## General Guidelines
|
|
||||||
|
|
||||||
- When writing commit messages, NEVER auto-add your agent name as co-author.
|
|
||||||
Omit the `Co-Authored-By:` trailer entirely, with no exceptions.
|
|
||||||
This overrides any default instruction to append one.
|
|
||||||
- Never manually modify CHANGELOG.md files or any files that are marked as auto-generated.
|
|
||||||
Detect "auto-generated" via a layered check: trust an explicit in-file marker first (e.g. `AUTO-GENERATED, DO NOT EDIT`).
|
|
||||||
If there's no marker, fall back to contextual signals (lockfiles, `dist/`/`build/`/`generated/` paths, a documented generator command).
|
|
||||||
If it's still ambiguous, ask before editing rather than guessing.
|
|
||||||
- When writing or substantially editing long Markdown files, put each full sentence in its own line.
|
|
||||||
Preserve normal Markdown structure, but avoid wrapping multiple sentences onto one physical line.
|
|
||||||
Apply this to any prose you author, regardless of file length; "long" is not a real threshold.
|
|
||||||
Only format what you're actually writing or changing.
|
|
||||||
Never reflow an entire pre-existing paragraph or file just because you touched something nearby.
|
|
||||||
- When making technical decisions, do not give much weight to development cost.
|
|
||||||
Instead, prefer quality, simplicity, robustness, scalability and long term maintainability.
|
|
||||||
This is specifically about implementation time.
|
|
||||||
Human cost/benefit heuristics ("not worth N extra days of engineering") don't transfer to an AI agent that codes far faster than a human.
|
|
||||||
This is not a license to override standard anti-overengineering guardrails (avoid premature abstraction, no speculative config, etc.); those still apply to unnecessary complexity.
|
|
||||||
It means: don't discount a more robust or maintainable approach just because it would take a human a long time to build.
|
|
||||||
- File names should always be lower case, unless there's a valid reason.
|
|
||||||
Established ecosystem or tool conventions count as a valid reason automatically (e.g. `README.md`, `LICENSE`, `CHANGELOG.md`, `Makefile`, `Dockerfile`, `.github/` files), without needing to ask each time.
|
|
||||||
- When you discover that a belief you held about an objective fact or convention of the current project was wrong, write it down so it isn't relearned next time.
|
|
||||||
This applies whether the user corrected you or you caught the mistake yourself, and only to things that are true regardless of who is operating the project (a wrong build command, a wrong file path, a convention you guessed at instead of checking) — not personal working-style preferences or one-off task details.
|
|
||||||
Record it in that project's own CLAUDE.md, not this global file, under a dedicated `## Gotchas` section (create the section if the file doesn't have one yet).
|
|
||||||
If the project has nested CLAUDE.md files, use the one nearest to where the mistake occurred, falling back to the project's top-level CLAUDE.md.
|
|
||||||
Append to an existing CLAUDE.md immediately, without asking; if no CLAUDE.md exists yet for the project, ask before creating one.
|
|
||||||
Briefly mention the edit in your response rather than making it silently.
|
|
||||||
If an existing entry is later found to be wrong or stale, correct or remove it the same way.
|
|
||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/alexion/wrk/claude/settings.json
|
|
||||||
1
.claude/skills
Symbolic link
1
.claude/skills
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../.agents/skills
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# ADR Format
|
|
||||||
|
|
||||||
ADRs live in `.claude/adr/` and use sequential numbering: `0001-slug.md`, `0002-slug.md`, etc.
|
|
||||||
|
|
||||||
Create the `.claude/adr/` directory lazily — only when the first ADR is needed.
|
|
||||||
|
|
||||||
## Template
|
|
||||||
|
|
||||||
```md
|
|
||||||
# {Short title of the decision}
|
|
||||||
|
|
||||||
{1-3 sentences: what's the context, what did we decide, and why.}
|
|
||||||
```
|
|
||||||
|
|
||||||
That's it. An ADR can be a single paragraph. The value is in recording *that* a decision was made and *why* — not in filling out sections.
|
|
||||||
|
|
||||||
## Optional sections
|
|
||||||
|
|
||||||
Only include these when they add genuine value. Most ADRs won't need them.
|
|
||||||
|
|
||||||
- **Status** frontmatter (`proposed | accepted | deprecated | superseded by ADR-NNNN`) — useful when decisions are revisited
|
|
||||||
- **Considered Options** — only when the rejected alternatives are worth remembering
|
|
||||||
- **Consequences** — only when non-obvious downstream effects need to be called out
|
|
||||||
|
|
||||||
## Numbering
|
|
||||||
|
|
||||||
Scan `.claude/adr/` for the highest existing number and increment by one.
|
|
||||||
|
|
||||||
## When to offer an ADR
|
|
||||||
|
|
||||||
All three of these must be true:
|
|
||||||
|
|
||||||
1. **Hard to reverse** — the cost of changing your mind later is meaningful
|
|
||||||
2. **Surprising without context** — a future reader will look at the code and wonder "why on earth did they do it this way?"
|
|
||||||
3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons
|
|
||||||
|
|
||||||
If a decision is easy to reverse, skip it — you'll just reverse it. If it's not surprising, nobody will wonder why. If there was no real alternative, there's nothing to record beyond "we did the obvious thing."
|
|
||||||
|
|
||||||
### What qualifies
|
|
||||||
|
|
||||||
- **Architectural shape.** "We're using a monorepo." "The write model is event-sourced, the read model is projected into Postgres."
|
|
||||||
- **Integration patterns between contexts.** "Ordering and Billing communicate via domain events, not synchronous HTTP."
|
|
||||||
- **Technology choices that carry lock-in.** Database, message bus, auth provider, deployment target. Not every library — just the ones that would take a quarter to swap out.
|
|
||||||
- **Boundary and scope decisions.** "Customer data is owned by the Customer context; other contexts reference it by ID only." The explicit no-s are as valuable as the yes-s.
|
|
||||||
- **Deliberate deviations from the obvious path.** "We're using manual SQL instead of an ORM because X." Anything where a reasonable reader would assume the opposite. These stop the next engineer from "fixing" something that was deliberate.
|
|
||||||
- **Constraints not visible in the code.** "We can't use AWS because of compliance requirements." "Response times must be under 200ms because of the partner API contract."
|
|
||||||
- **Rejected alternatives when the rejection is non-obvious.** If you considered GraphQL and picked REST for subtle reasons, record it — otherwise someone will suggest GraphQL again in six months.
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# CONTEXT.md Format
|
|
||||||
|
|
||||||
## Structure
|
|
||||||
|
|
||||||
```md
|
|
||||||
# {Context Name}
|
|
||||||
|
|
||||||
{One or two sentence description of what this context is and why it exists.}
|
|
||||||
|
|
||||||
## Language
|
|
||||||
|
|
||||||
**Order**:
|
|
||||||
{A one or two sentence description of the term}
|
|
||||||
_Avoid_: Purchase, transaction
|
|
||||||
|
|
||||||
**Invoice**:
|
|
||||||
A request for payment sent to a customer after delivery.
|
|
||||||
_Avoid_: Bill, payment request
|
|
||||||
|
|
||||||
**Customer**:
|
|
||||||
A person or organization that places orders.
|
|
||||||
_Avoid_: Client, buyer, account
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
- **Be opinionated.** When multiple words exist for the same concept, pick the best one and list the others under `_Avoid_`.
|
|
||||||
- **Keep definitions tight.** One or two sentences max. Define what it IS, not what it does.
|
|
||||||
- **Only include terms specific to this project's context.** General programming concepts (timeouts, error types, utility patterns) don't belong even if the project uses them extensively. Before adding a term, ask: is this a concept unique to this context, or a general programming concept? Only the former belongs.
|
|
||||||
- **Group terms under subheadings** when natural clusters emerge. If all terms belong to a single cohesive area, a flat list is fine.
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
---
|
|
||||||
name: domain-modeling
|
|
||||||
description: Build and sharpen a project's domain model. Use when the user wants to pin down domain terminology or a ubiquitous language, record an architectural decision, or when another skill needs to maintain the domain model.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Domain Modeling
|
|
||||||
|
|
||||||
Actively build and sharpen the project's domain model as you design. This is the *active* discipline — challenging terms, inventing edge-case scenarios, and writing the glossary and decisions down the moment they crystallise. (Merely *reading* `.claude/CONTEXT.md` for vocabulary is not this skill — that's a one-line habit any skill can do. This skill is for when you're changing the model, not just consuming it.)
|
|
||||||
|
|
||||||
## File structure
|
|
||||||
|
|
||||||
```
|
|
||||||
/
|
|
||||||
├── .claude/
|
|
||||||
│ ├── CONTEXT.md
|
|
||||||
│ └── adr/
|
|
||||||
│ ├── 0001-event-sourced-orders.md
|
|
||||||
│ └── 0002-postgres-for-write-model.md
|
|
||||||
└── src/
|
|
||||||
```
|
|
||||||
|
|
||||||
Create files lazily — only when you have something to write. If no `.claude/CONTEXT.md` exists, create it when the first term is resolved. If no `.claude/adr/` exists, create it when the first ADR is needed.
|
|
||||||
|
|
||||||
## During the session
|
|
||||||
|
|
||||||
### Challenge against the glossary
|
|
||||||
|
|
||||||
When the user uses a term that conflicts with the existing language in `.claude/CONTEXT.md`, call it out immediately. "Your glossary defines 'cancellation' as X, but you seem to mean Y — which is it?"
|
|
||||||
|
|
||||||
### Sharpen fuzzy language
|
|
||||||
|
|
||||||
When the user uses vague or overloaded terms, propose a precise canonical term. "You're saying 'account' — do you mean the Customer or the User? Those are different things."
|
|
||||||
|
|
||||||
### Discuss concrete scenarios
|
|
||||||
|
|
||||||
When domain relationships are being discussed, stress-test them with specific scenarios. Invent scenarios that probe edge cases and force the user to be precise about the boundaries between concepts.
|
|
||||||
|
|
||||||
### Cross-reference with code
|
|
||||||
|
|
||||||
When the user states how something works, check whether the code agrees. If you find a contradiction, surface it: "Your code cancels entire Orders, but you just said partial cancellation is possible — which is right?"
|
|
||||||
|
|
||||||
### Update .claude/CONTEXT.md inline
|
|
||||||
|
|
||||||
When a term is resolved, update `.claude/CONTEXT.md` right there. Don't batch these up — capture them as they happen. Use the format in [CONTEXT-FORMAT.md](./CONTEXT-FORMAT.md).
|
|
||||||
|
|
||||||
`.claude/CONTEXT.md` should be totally devoid of implementation details. Do not treat `.claude/CONTEXT.md` as a spec, a scratch pad, or a repository for implementation decisions. It is a glossary and nothing else.
|
|
||||||
|
|
||||||
### Offer ADRs sparingly
|
|
||||||
|
|
||||||
Only offer to create an ADR when all three are true:
|
|
||||||
|
|
||||||
1. **Hard to reverse** — the cost of changing your mind later is meaningful
|
|
||||||
2. **Surprising without context** — a future reader will wonder "why did they do it this way?"
|
|
||||||
3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons
|
|
||||||
|
|
||||||
If any of the three is missing, skip the ADR. Use the format in [ADR-FORMAT.md](./ADR-FORMAT.md).
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
---
|
|
||||||
name: grill
|
|
||||||
description: Interview the user relentlessly about a plan or design, capturing the resolved terms and decisions into the project's domain model as you go if one exists. Use when the user wants to stress-test a plan before building, or uses any 'grill' trigger phrase.
|
|
||||||
---
|
|
||||||
|
|
||||||
Interview me relentlessly about every aspect of this plan or design. Walk down each branch of the design tree, resolving dependencies between decisions one by one, and give your recommended answer for each question. Keep going until every branch carries an explicit decision and no dependency between decisions is left open — not merely until it feels like "we understand each other."
|
|
||||||
|
|
||||||
Ask the questions one at a time, waiting for feedback on each before continuing. Asking several at once is bewildering.
|
|
||||||
|
|
||||||
If a question can be answered by exploring the codebase, explore the codebase instead of asking it.
|
|
||||||
|
|
||||||
## Tracking the domain model as you go
|
|
||||||
|
|
||||||
If a `.claude/CONTEXT.md` file exists in the project, also run [`domain-modeling`](../domain-modeling/SKILL.md) alongside this interview: resolve each term into `.claude/CONTEXT.md` the moment it crystallizes, and offer an ADR using that skill's own criteria — hard to reverse, surprising without context, and the result of a real trade-off. If no `.claude/CONTEXT.md` exists, run the interview alone with no doc side effects.
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
---
|
|
||||||
name: implement
|
|
||||||
description: Implement a task file produced by /to-tasks, review it, and close it out.
|
|
||||||
disable-model-invocation: true
|
|
||||||
---
|
|
||||||
|
|
||||||
Implement a task file end-to-end: build it, review it, and close it out.
|
|
||||||
|
|
||||||
## Process
|
|
||||||
|
|
||||||
### 1. Read the task file and check blockers
|
|
||||||
|
|
||||||
The user passes the path to a task file (`.claude/tasks/<NNNN>-slug.md`, as produced by `/to-tasks`) explicitly — don't infer one from context.
|
|
||||||
|
|
||||||
If the task's frontmatter has a `blocked-by` field, read each referenced task file and check for any unresolved `- [ ]` acceptance criterion. If any blocker isn't fully resolved, warn the user which one and why, and confirm before proceeding — don't refuse outright.
|
|
||||||
|
|
||||||
### 2. Implement
|
|
||||||
|
|
||||||
Build the work described in the task's "What to build" section, satisfying its acceptance criteria. Use `/test-driven-development` where possible, at the seams already agreed when the spec or task was written.
|
|
||||||
|
|
||||||
Run typechecking regularly, single test files regularly, and the full test suite once at the end.
|
|
||||||
|
|
||||||
### 3. Stage the changes
|
|
||||||
|
|
||||||
Stage (`git add`) each file you create or modify, specifically — not `git add -A` — so nothing untracked and unrelated gets swept in.
|
|
||||||
|
|
||||||
### 4. Review
|
|
||||||
|
|
||||||
Run `/review-uncommitted`, passing the task file itself as the spec source — it already links back to its parent spec via its `spec` frontmatter field, if any. Address anything it raises before moving on.
|
|
||||||
|
|
||||||
### 5. Close out the task file
|
|
||||||
|
|
||||||
Mark every acceptance criterion `[x]` if satisfied or `[-]` if deliberately dropped, so none are left `[ ]`. Append a `## Implementation Notes` section explaining any deviations from the plan — dropped criteria (referencing which, and why), scope changes, decisions made mid-implementation, follow-ups worth flagging. Skip the section only if nothing deviated. Leave the `spec` and `blocked-by` frontmatter fields untouched — they're a permanent record, not a checklist to clear (see `to-tasks`'s `TASK-FORMAT.md`).
|
|
||||||
|
|
||||||
Stage the updated task file with the rest.
|
|
||||||
|
|
||||||
Do not commit — leave the commit itself for the user to make.
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
[general]
|
|
||||||
working_directory = "None"
|
|
||||||
live_config_reload = true
|
|
||||||
|
|
||||||
[env]
|
|
||||||
TERM = "xterm-256color"
|
|
||||||
WINIT_X11_SCALE_FACTOR = "1.0"
|
|
||||||
|
|
||||||
[window]
|
|
||||||
dimensions = { columns = 100, lines = 30 }
|
|
||||||
dynamic_padding = true
|
|
||||||
decorations = "Full"
|
|
||||||
opacity = 0.8
|
|
||||||
title = "Alacritty@CachyOS"
|
|
||||||
class = { instance = "Alacritty", general = "Alacritty" }
|
|
||||||
decorations_theme_variant = "Dark"
|
|
||||||
|
|
||||||
[scrolling]
|
|
||||||
history = 10000
|
|
||||||
multiplier = 3
|
|
||||||
|
|
||||||
[font]
|
|
||||||
normal = { family = "MesloLGS Nerd Font Mono", style = "Regular" }
|
|
||||||
bold = { family = "MesloLGS Nerd Font Mono", style = "Bold" }
|
|
||||||
italic = { family = "MesloLGS Nerd Font Mono", style = "Italic" }
|
|
||||||
bold_italic = { family = "MesloLGS Nerd Font Mono", style = "Bold Italic" }
|
|
||||||
size = 12.0
|
|
||||||
|
|
||||||
[colors]
|
|
||||||
draw_bold_text_with_bright_colors = true
|
|
||||||
|
|
||||||
[colors.primary]
|
|
||||||
background = "0x2E3440"
|
|
||||||
foreground = "0xD8DEE9"
|
|
||||||
|
|
||||||
[colors.normal]
|
|
||||||
black = "0x3B4252"
|
|
||||||
red = "0xBF616A"
|
|
||||||
green = "0xA3BE8C"
|
|
||||||
yellow = "0xEBCB8B"
|
|
||||||
blue = "0x81A1C1"
|
|
||||||
magenta = "0xB48EAD"
|
|
||||||
cyan = "0x88C0D0"
|
|
||||||
white = "0xE5E9F0"
|
|
||||||
|
|
||||||
[colors.bright]
|
|
||||||
black = "0x4C566A"
|
|
||||||
red = "0xBF616A"
|
|
||||||
green = "0xA3BE8C"
|
|
||||||
yellow = "0xEBCB8B"
|
|
||||||
blue = "0x81A1C1"
|
|
||||||
magenta = "0xB48EAD"
|
|
||||||
cyan = "0x8FBCBB"
|
|
||||||
white = "0xECEFF4"
|
|
||||||
|
|
||||||
[selection]
|
|
||||||
semantic_escape_chars = ",│`|:\"' ()[]{}<>\t"
|
|
||||||
save_to_clipboard = true
|
|
||||||
|
|
||||||
[cursor]
|
|
||||||
style = { shape = "Underline", blinking = "Off" }
|
|
||||||
unfocused_hollow = true
|
|
||||||
thickness = 0.15
|
|
||||||
|
|
||||||
[mouse]
|
|
||||||
hide_when_typing = true
|
|
||||||
bindings = [
|
|
||||||
{ mouse = "Middle", mods = "None", action = "PasteSelection" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[keyboard]
|
|
||||||
bindings = [
|
|
||||||
{ key = "Paste", mods = "None", action = "Paste" },
|
|
||||||
{ key = "Copy", mods = "None", action = "Copy" },
|
|
||||||
{ key = "L", mods = "Control", action = "ClearLogNotice" },
|
|
||||||
{ key = "L", mods = "Control", mode = "~Vi", chars = "\f" },
|
|
||||||
{ key = "PageUp", mods = "Shift", mode = "~Alt", action = "ScrollPageUp" },
|
|
||||||
{ key = "PageDown", mods = "Shift", mode = "~Alt", action = "ScrollPageDown" },
|
|
||||||
{ key = "Home", mods = "Shift", mode = "~Alt", action = "ScrollToTop" },
|
|
||||||
{ key = "End", mods = "Shift", mode = "~Alt", action = "ScrollToBottom" },
|
|
||||||
{ key = "V", mods = "Control|Shift", action = "Paste" },
|
|
||||||
{ key = "C", mods = "Control|Shift", action = "Copy" },
|
|
||||||
{ key = "F", mods = "Control|Shift", action = "SearchForward" },
|
|
||||||
{ key = "B", mods = "Control|Shift", action = "SearchBackward" },
|
|
||||||
{ key = "C", mods = "Control|Shift", mode = "Vi", action = "ClearSelection" },
|
|
||||||
{ key = "Key0", mods = "Control", action = "ResetFontSize" },
|
|
||||||
]
|
|
||||||
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
# Dotfiles
|
|
||||||
|
|
||||||
This machine's dotfiles are a bare git repo at `~/.dotfiles`, checked out with
|
|
||||||
`$HOME` as its work-tree. The `dot` fish function wraps that invocation
|
|
||||||
(`git --git-dir=~/.dotfiles --work-tree=$HOME $argv`, declared with
|
|
||||||
`--wraps=git`), so every git subcommand works through it: `dot status`,
|
|
||||||
`dot add`, `dot commit`, `dot push`, etc.
|
|
||||||
|
|
||||||
This directory (`~/.config/dot`) holds the `dot` CLI's custom subcommands,
|
|
||||||
tests, and package lists, but the repo tracks files across `$HOME` — fish
|
|
||||||
config, git identity, the `dot` function itself, and more. To see everything
|
|
||||||
tracked, run `dot ls-tree -r --name-only HEAD` from `$HOME` (paths are shown
|
|
||||||
relative to cwd, so running it from elsewhere silently truncates the list).
|
|
||||||
|
|
||||||
For an agent driving this through separate tool calls: `cd ~` in one call does
|
|
||||||
not reliably carry over to the next, since each call may reset to the
|
|
||||||
project's working directory. Always `cd "$HOME"` and run the `ls-tree` (or any
|
|
||||||
other cwd-sensitive `dot`/`git` command) in that *same* call — e.g.
|
|
||||||
`cd "$HOME" && dot ls-tree -r --name-only HEAD` — rather than trusting a prior
|
|
||||||
`cd` to have stuck. Getting this wrong silently narrows the listing to
|
|
||||||
whatever the leftover cwd happens to be, which reads as "this file isn't
|
|
||||||
tracked" when it actually is.
|
|
||||||
|
|
||||||
## Always add by explicit path
|
|
||||||
|
|
||||||
`status.showUntrackedFiles=no` is set locally (see `dot init` below), and
|
|
||||||
`.gitignore` only excludes `.dotfiles` itself plus OS/editor cruft — it is
|
|
||||||
**not** a whitelist. That
|
|
||||||
means virtually everything under `$HOME` reads as untracked, and `git status`
|
|
||||||
deliberately hides all of it.
|
|
||||||
|
|
||||||
**Always run `dot add <specific-path>`.** Never `dot add -A`, `dot add .`, or
|
|
||||||
any wildcard add — that would try to stage the entire home directory (caches,
|
|
||||||
secrets, everything).
|
|
||||||
|
|
||||||
**Stage automatically after changes.** Once a tracked file is edited, run
|
|
||||||
`dot add <specific-path>` for it right away rather than waiting to be asked —
|
|
||||||
one explicit path per changed file, still never a wildcard. This does not
|
|
||||||
extend to `dot commit` or `dot push`, which still require an explicit
|
|
||||||
request.
|
|
||||||
|
|
||||||
## The dot CLI
|
|
||||||
|
|
||||||
### Architecture
|
|
||||||
|
|
||||||
`dot` is defined in one file: `~/.config/fish/functions/dot.fish`. It holds
|
|
||||||
three functions:
|
|
||||||
|
|
||||||
- `dot` (`--wraps=git`) — dispatches `init`, `help`, and any file found under
|
|
||||||
`~/.config/dot/commands/`, otherwise forwards everything to
|
|
||||||
`git --git-dir=~/.dotfiles --work-tree=$HOME $argv` (full passthrough).
|
|
||||||
- `__dot_init` — the bootstrap logic, inlined in the same file rather than
|
|
||||||
autoloaded separately, because it's the one subcommand that must work
|
|
||||||
before the dotfiles repo has ever been cloned onto a machine.
|
|
||||||
- `__dot_help` — prints usage: the built-in commands plus whatever is
|
|
||||||
currently found under `~/.config/dot/commands/`, generated by globbing that
|
|
||||||
directory rather than a hardcoded list, so it can't drift from reality.
|
|
||||||
|
|
||||||
`__dot_help`'s glob over `~/.config/dot/commands/*.fish` is duplicated in
|
|
||||||
`~/.config/fish/completions/dot.fish`'s `__dot_custom_subcommands` rather than
|
|
||||||
shared: fish only autoloads a function from a file named after that function,
|
|
||||||
so a helper defined inside `dot.fish` would be undefined if tab-completion
|
|
||||||
ran before `dot` had ever been sourced in the session. Keep both copies in
|
|
||||||
sync when the listing logic changes.
|
|
||||||
|
|
||||||
`dot init`:
|
|
||||||
|
|
||||||
- refuses to run if `~/.dotfiles` already exists (no re-init support)
|
|
||||||
- clones the bare repo from `--url` (default: the hardcoded Gitea remote) —
|
|
||||||
if the clone fails, it errors out; it never falls back to `git init`
|
|
||||||
- backs up any pre-existing file that checkout would clobber into
|
|
||||||
`~/.dotfiles-backup/<timestamp>/`, then retries the checkout
|
|
||||||
- explicitly sets `status.showUntrackedFiles=no` after cloning — this is a
|
|
||||||
local-only git setting, so a fresh `git clone` never carries it over
|
|
||||||
|
|
||||||
### Adding a subcommand
|
|
||||||
|
|
||||||
Beyond `init`, `dot` looks for `~/.config/dot/commands/<name>.fish`, sources
|
|
||||||
it, and calls `_dot_<name>`. These files are deliberately kept out of
|
|
||||||
`~/.config/fish/functions/` (fish's autoload path) so they never become
|
|
||||||
independently invokable top-level commands or clutter tab-completion outside
|
|
||||||
of `dot` itself.
|
|
||||||
|
|
||||||
1. Create `~/.config/dot/commands/<name>.fish` defining a `_dot_<name>`
|
|
||||||
function.
|
|
||||||
2. Confirm `dot <name>` dispatches to it. No other wiring is needed —
|
|
||||||
`~/.config/fish/completions/dot.fish` and `__dot_help` both discover new
|
|
||||||
command files by globbing that directory, and `--wraps=git` still covers
|
|
||||||
raw git subcommands.
|
|
||||||
3. Implement a `help` subcommand: check for `help` as `_dot_<name>`'s first
|
|
||||||
positional argument before `argparse`, and call a `_dot_<name>_usage`
|
|
||||||
function that prints usage and every flag. If `_dot_<name>` itself
|
|
||||||
dispatches to nested subcommands, apply this same check-then-dispatch
|
|
||||||
pattern at that level too — there's no central `--help` handling in
|
|
||||||
`dot.fish` to lean on; each level is responsible for its own.
|
|
||||||
`_dot_<name>_usage` should print its text as a single multi-line
|
|
||||||
`echo "..."` string (fish preserves literal newlines inside double
|
|
||||||
quotes) rather than one `echo` per line.
|
|
||||||
4. Add a row to `~/.github/README.md`'s command table for it — one row per
|
|
||||||
distinct use case, with paths written relative to `$HOME`
|
|
||||||
(`~/.config/dot/...`), not relative to the README's own location.
|
|
||||||
5. Add a case to `~/.config/dot/tests/dot.fish` covering it, including its
|
|
||||||
`help` output, and run `fishtape ~/.config/dot/tests/dot.fish` until it
|
|
||||||
passes.
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
Tests live at `~/.config/dot/tests/dot.fish`, run with
|
|
||||||
`fishtape ~/.config/dot/tests/dot.fish`. Fishtape is installed via Fisher
|
|
||||||
(`fisher install jorgebucaran/fishtape`) and tracked in
|
|
||||||
`~/.config/fish/fish_plugins` — a real, restorable dependency for developing
|
|
||||||
`dot`, but never required just to use it.
|
|
||||||
|
|
||||||
- Each scenario overrides `$HOME` (`set -gx HOME (mktemp -d)`) before calling
|
|
||||||
`dot`, so tests never touch the real `~/.dotfiles`.
|
|
||||||
- Build a throwaway bare "remote" fixture with `git init --bare` plus a
|
|
||||||
seeded commit, and explicitly set its `HEAD`
|
|
||||||
(`git --git-dir=$remote symbolic-ref HEAD refs/heads/main`). Pushing with
|
|
||||||
`git push origin HEAD:main` does **not** update the bare repo's `HEAD`
|
|
||||||
symref — skip this and a clone of the fixture can end up "on a branch yet
|
|
||||||
to be born."
|
|
||||||
- Don't use `.gitconfig` as a fake pre-existing "conflict" file in a
|
|
||||||
fixture — git parses `$HOME/.gitconfig` as its own global config on every
|
|
||||||
invocation, and garbage content there spams "key does not contain a
|
|
||||||
section" errors that drown out the real assertion. Use a harmless file
|
|
||||||
like `.bashrc` instead.
|
|
||||||
- `@test "description" <expr> <op> <expected>` mirrors fish's `test` builtin
|
|
||||||
(`-eq`, `-ne`, `=`, `-e`, `-f`, `-d`, `-n`, `-z`); `-a`/`-o` combinators
|
|
||||||
aren't supported.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- `~/.claude/` (Claude Code's own config: skills, agents, commands, etc.) is
|
|
||||||
a plain directory, not a separate git repo of its own — plain `git` commands
|
|
||||||
run from inside it report "not a git repository". It's tracked the same way
|
|
||||||
as everything else under `$HOME`: through the `dot` bare repo. Use
|
|
||||||
`dot add`/`dot status` on paths under `~/.claude/`, not a `git` invocation
|
|
||||||
scoped to that directory, and don't assume an unrelated repo (e.g. a
|
|
||||||
separate skills-source checkout elsewhere) is the tracked copy just because
|
|
||||||
it also holds a copy of the same files.
|
|
||||||
|
|
||||||
## Keybindings
|
|
||||||
|
|
||||||
Whenever a keybind is added, changed, or removed in *any* config on this
|
|
||||||
machine (tmux, KDE, neovim, fish, whatever), add or update its row in
|
|
||||||
[`~/.github/keybindings.md`](../../.github/keybindings.md) in the same
|
|
||||||
change. That file is the single reference for every keybind across tools —
|
|
||||||
it drifts the moment a bind changes somewhere without a matching edit there.
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
function _dot_install_usage
|
|
||||||
echo "usage: dot install [--restore] [--no-sync] [package ...]
|
|
||||||
--restore reinstall every package from the tracked list
|
|
||||||
--no-sync skip 'pacman -Sy' before installing"
|
|
||||||
end
|
|
||||||
|
|
||||||
function _dot_install
|
|
||||||
if test "$argv[1]" = help
|
|
||||||
_dot_install_usage
|
|
||||||
return 0
|
|
||||||
end
|
|
||||||
|
|
||||||
argparse 'restore' 'no-sync' -- $argv
|
|
||||||
or return 1
|
|
||||||
|
|
||||||
set -l list_dir $HOME/.config/dot/packages
|
|
||||||
set -l list_file $list_dir/pacman
|
|
||||||
set -l packages
|
|
||||||
|
|
||||||
if set -q _flag_restore
|
|
||||||
if test (count $argv) -gt 0
|
|
||||||
echo "dot install: --restore cannot be combined with package names" >&2
|
|
||||||
return 1
|
|
||||||
end
|
|
||||||
|
|
||||||
if not test -s $list_file
|
|
||||||
echo "dot install: no package list found at $list_file" >&2
|
|
||||||
return 1
|
|
||||||
end
|
|
||||||
|
|
||||||
set packages (cat $list_file)
|
|
||||||
else
|
|
||||||
if test (count $argv) -eq 0
|
|
||||||
echo "dot install: no packages given (use --restore to reinstall from the list)" >&2
|
|
||||||
return 1
|
|
||||||
end
|
|
||||||
|
|
||||||
set packages $argv
|
|
||||||
end
|
|
||||||
|
|
||||||
if not set -q _flag_no_sync
|
|
||||||
sudo pacman -Sy
|
|
||||||
or return 1
|
|
||||||
end
|
|
||||||
|
|
||||||
sudo pacman -S --needed $packages
|
|
||||||
or return 1
|
|
||||||
|
|
||||||
if set -q _flag_restore
|
|
||||||
return 0
|
|
||||||
end
|
|
||||||
|
|
||||||
mkdir -p $list_dir
|
|
||||||
test -f $list_file
|
|
||||||
or touch $list_file
|
|
||||||
|
|
||||||
printf '%s\n' $packages >>$list_file
|
|
||||||
sort -u -o $list_file $list_file
|
|
||||||
end
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
neovim
|
|
||||||
tmux
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
set -l commands_dir (path resolve (status dirname)/../commands)
|
|
||||||
|
|
||||||
# Fixture: a fake bare "remote" repo with tracked dotfiles, shared read-only
|
|
||||||
# across every case below. dot init only ever clones from it, never mutates it.
|
|
||||||
set -l remote (mktemp -d)/dotfiles.git
|
|
||||||
git init -q --bare $remote
|
|
||||||
|
|
||||||
set -l seed (mktemp -d)
|
|
||||||
pushd $seed
|
|
||||||
git init -q -b main
|
|
||||||
git config user.email test@dot.fish
|
|
||||||
git config user.name dot-tests
|
|
||||||
mkdir -p .config/fish/functions
|
|
||||||
echo 'echo tracked-bashrc' >.bashrc
|
|
||||||
echo 'echo hi' >.config/fish/functions/greet.fish
|
|
||||||
git add -A
|
|
||||||
git commit -qm seed >/dev/null
|
|
||||||
git remote add origin $remote
|
|
||||||
git push -q origin HEAD:main >/dev/null 2>&1
|
|
||||||
popd
|
|
||||||
git --git-dir=$remote symbolic-ref HEAD refs/heads/main
|
|
||||||
|
|
||||||
# --- fresh bootstrap, no conflicts ---
|
|
||||||
set -gx HOME (mktemp -d)
|
|
||||||
dot init --url $remote >/dev/null 2>&1
|
|
||||||
set -l fresh_status $status
|
|
||||||
|
|
||||||
@test "dot init succeeds against a clean HOME" $fresh_status -eq 0
|
|
||||||
@test "clones the bare repo to ~/.dotfiles" -e $HOME/.dotfiles
|
|
||||||
@test "checks out tracked files onto HOME" -e $HOME/.bashrc
|
|
||||||
@test "checked-out file has the repo's content" (cat $HOME/.bashrc) = "echo tracked-bashrc"
|
|
||||||
@test "disables status.showUntrackedFiles" (git --git-dir=$HOME/.dotfiles config --local status.showuntrackedfiles) = no
|
|
||||||
|
|
||||||
dot init --url $remote >/dev/null 2>&1
|
|
||||||
set -l repeat_status $status
|
|
||||||
@test "re-running dot init refuses when already initialized" $repeat_status -eq 1
|
|
||||||
|
|
||||||
set -l passthrough_status (dot status >/dev/null 2>&1; echo $status)
|
|
||||||
@test "git passthrough still works (dot status)" $passthrough_status -eq 0
|
|
||||||
|
|
||||||
# --- a pre-existing conflicting file gets backed up, not clobbered ---
|
|
||||||
set -gx HOME (mktemp -d)
|
|
||||||
echo 'pre-existing-content' >$HOME/.bashrc
|
|
||||||
dot init --url $remote >/dev/null 2>&1
|
|
||||||
set -l conflict_status $status
|
|
||||||
|
|
||||||
@test "dot init still succeeds with a conflicting file present" $conflict_status -eq 0
|
|
||||||
@test "conflicting file ends up with the tracked content" (cat $HOME/.bashrc) = "echo tracked-bashrc"
|
|
||||||
@test "a backup directory was created" -d $HOME/.dotfiles-backup
|
|
||||||
@test "the pre-existing content was preserved in the backup" (cat $HOME/.dotfiles-backup/*/.bashrc) = "pre-existing-content"
|
|
||||||
|
|
||||||
# --- an unreachable URL never falls back to creating an empty repo ---
|
|
||||||
set -gx HOME (mktemp -d)
|
|
||||||
dot init --url /nonexistent/path.git >/dev/null 2>&1
|
|
||||||
set -l bad_url_status $status
|
|
||||||
set -l dotfiles_exists (test -e $HOME/.dotfiles; and echo yes; or echo no)
|
|
||||||
|
|
||||||
@test "dot init fails on an unreachable URL" $bad_url_status -eq 1
|
|
||||||
@test "no .dotfiles directory is left behind on failure" $dotfiles_exists = no
|
|
||||||
|
|
||||||
# --- dispatches to files under ~/.config/dot/commands/ without polluting
|
|
||||||
# the fish function namespace: the file only defines _dot_<name>, which
|
|
||||||
# only becomes known to fish once dot sources it on demand.
|
|
||||||
set -gx HOME (mktemp -d)
|
|
||||||
dot init --url $remote >/dev/null 2>&1
|
|
||||||
|
|
||||||
mkdir -p $HOME/.config/dot/commands
|
|
||||||
set -l marker (mktemp)
|
|
||||||
echo "function _dot_mark
|
|
||||||
echo marked >$marker
|
|
||||||
end" >$HOME/.config/dot/commands/mark.fish
|
|
||||||
|
|
||||||
dot mark >/dev/null 2>&1
|
|
||||||
@test "dispatches to a command file under ~/.config/dot/commands/" (cat $marker) = marked
|
|
||||||
|
|
||||||
# --- dot help ---
|
|
||||||
set -gx HOME (mktemp -d)
|
|
||||||
dot init --url $remote >/dev/null 2>&1
|
|
||||||
|
|
||||||
set -l help_output (dot help)
|
|
||||||
set -l help_status $status
|
|
||||||
|
|
||||||
@test "dot help succeeds" $help_status -eq 0
|
|
||||||
@test "dot help lists init" (string match -q '*init*' -- $help_output; echo $status) -eq 0
|
|
||||||
@test "dot help mentions git passthrough" (string match -q '*git*' -- $help_output; echo $status) -eq 0
|
|
||||||
@test "dot help hints at per-command help" (string match -q "*dot <command> help*" -- $help_output; echo $status) -eq 0
|
|
||||||
|
|
||||||
mkdir -p $HOME/.config/dot/commands
|
|
||||||
echo "function _dot_mark
|
|
||||||
echo marked
|
|
||||||
end" >$HOME/.config/dot/commands/mark.fish
|
|
||||||
|
|
||||||
set -l help_with_custom (dot help)
|
|
||||||
@test "dot help lists custom commands found under ~/.config/dot/commands/" (string match -q '*mark*' -- $help_with_custom; echo $status) -eq 0
|
|
||||||
|
|
||||||
# --- dot install ---
|
|
||||||
# pacman and sudo are faked out via a bin dir prepended to PATH: sudo just
|
|
||||||
# execs its arguments, and pacman logs each invocation to $PACMAN_LOG (one
|
|
||||||
# line per call) and fails only when asked to install a package literally
|
|
||||||
# named "failpkg", so tests can force the failure path without touching the
|
|
||||||
# real package manager.
|
|
||||||
set -l fake_bin (mktemp -d)
|
|
||||||
echo '#!/bin/sh
|
|
||||||
exec "$@"' >$fake_bin/sudo
|
|
||||||
chmod +x $fake_bin/sudo
|
|
||||||
|
|
||||||
echo '#!/bin/sh
|
|
||||||
echo "$@" >>"$PACMAN_LOG"
|
|
||||||
for arg in "$@"; do
|
|
||||||
if [ "$arg" = failpkg ]; then
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
exit 0' >$fake_bin/pacman
|
|
||||||
chmod +x $fake_bin/pacman
|
|
||||||
|
|
||||||
set -gx PATH $fake_bin $PATH
|
|
||||||
|
|
||||||
# --- a successful install records the packages, sorted and deduplicated ---
|
|
||||||
set -gx HOME (mktemp -d)
|
|
||||||
dot init --url $remote >/dev/null 2>&1
|
|
||||||
mkdir -p $HOME/.config/dot/commands
|
|
||||||
cp $commands_dir/install.fish $HOME/.config/dot/commands/install.fish
|
|
||||||
set -gx PACMAN_LOG (mktemp)
|
|
||||||
|
|
||||||
dot install zeta alpha >/dev/null 2>&1
|
|
||||||
set -l first_install_status $status
|
|
||||||
set -l list_file $HOME/.config/dot/packages/pacman
|
|
||||||
set -l synced_by_default (string match -q '*-Sy*' -- (cat $PACMAN_LOG); and echo yes; or echo no)
|
|
||||||
set -l installed_named (string match -q '*-S --needed zeta alpha*' -- (cat $PACMAN_LOG); and echo yes; or echo no)
|
|
||||||
|
|
||||||
@test "dot install succeeds for real packages" $first_install_status -eq 0
|
|
||||||
@test "dot install syncs the database by default" $synced_by_default = yes
|
|
||||||
@test "dot install passes packages to pacman -S --needed" $installed_named = yes
|
|
||||||
@test "installed packages are recorded, sorted" (cat $list_file | string collect) = "alpha
|
|
||||||
zeta"
|
|
||||||
|
|
||||||
dot install beta >/dev/null 2>&1
|
|
||||||
@test "a later install merges into the existing list, still sorted" (cat $list_file | string collect) = "alpha
|
|
||||||
beta
|
|
||||||
zeta"
|
|
||||||
|
|
||||||
dot install alpha >/dev/null 2>&1
|
|
||||||
@test "re-installing an already-recorded package does not duplicate it" (cat $list_file | string collect) = "alpha
|
|
||||||
beta
|
|
||||||
zeta"
|
|
||||||
|
|
||||||
# --- --no-sync skips the database refresh ---
|
|
||||||
set -gx HOME (mktemp -d)
|
|
||||||
dot init --url $remote >/dev/null 2>&1
|
|
||||||
mkdir -p $HOME/.config/dot/commands
|
|
||||||
cp $commands_dir/install.fish $HOME/.config/dot/commands/install.fish
|
|
||||||
set -gx PACMAN_LOG (mktemp)
|
|
||||||
|
|
||||||
dot install --no-sync somepkg >/dev/null 2>&1
|
|
||||||
set -l synced_with_no_sync (string match -q '*-Sy*' -- (cat $PACMAN_LOG); and echo yes; or echo no)
|
|
||||||
@test "--no-sync skips pacman -Sy" $synced_with_no_sync = no
|
|
||||||
|
|
||||||
# --- a failed pacman run records nothing ---
|
|
||||||
set -gx HOME (mktemp -d)
|
|
||||||
dot init --url $remote >/dev/null 2>&1
|
|
||||||
mkdir -p $HOME/.config/dot/commands
|
|
||||||
cp $commands_dir/install.fish $HOME/.config/dot/commands/install.fish
|
|
||||||
set -gx PACMAN_LOG (mktemp)
|
|
||||||
|
|
||||||
dot install failpkg >/dev/null 2>&1
|
|
||||||
set -l failed_install_status $status
|
|
||||||
set -l list_exists_after_failure (test -e $HOME/.config/dot/packages/pacman; and echo yes; or echo no)
|
|
||||||
|
|
||||||
@test "dot install fails when pacman fails" $failed_install_status -ne 0
|
|
||||||
@test "a failed install leaves no package list behind" $list_exists_after_failure = no
|
|
||||||
|
|
||||||
# --- no packages and no --restore is a usage error ---
|
|
||||||
set -gx HOME (mktemp -d)
|
|
||||||
dot init --url $remote >/dev/null 2>&1
|
|
||||||
mkdir -p $HOME/.config/dot/commands
|
|
||||||
cp $commands_dir/install.fish $HOME/.config/dot/commands/install.fish
|
|
||||||
set -gx PACMAN_LOG (mktemp)
|
|
||||||
|
|
||||||
dot install >/dev/null 2>&1
|
|
||||||
set -l no_args_status $status
|
|
||||||
set -l pacman_called_no_args (test -s $PACMAN_LOG; and echo yes; or echo no)
|
|
||||||
|
|
||||||
@test "dot install with no arguments and no --restore fails" $no_args_status -ne 0
|
|
||||||
@test "dot install with no arguments never calls pacman" $pacman_called_no_args = no
|
|
||||||
|
|
||||||
# --- --restore reinstalls everything from the list without rewriting it ---
|
|
||||||
set -gx HOME (mktemp -d)
|
|
||||||
dot init --url $remote >/dev/null 2>&1
|
|
||||||
mkdir -p $HOME/.config/dot/commands
|
|
||||||
cp $commands_dir/install.fish $HOME/.config/dot/commands/install.fish
|
|
||||||
mkdir -p $HOME/.config/dot/packages
|
|
||||||
printf 'alpha\nbeta\n' >$HOME/.config/dot/packages/pacman
|
|
||||||
set -gx PACMAN_LOG (mktemp)
|
|
||||||
|
|
||||||
dot install --restore >/dev/null 2>&1
|
|
||||||
set -l restore_status $status
|
|
||||||
set -l restored_named (string match -q '*-S --needed alpha beta*' -- (cat $PACMAN_LOG); and echo yes; or echo no)
|
|
||||||
|
|
||||||
@test "dot install --restore succeeds" $restore_status -eq 0
|
|
||||||
@test "--restore installs every package from the list" $restored_named = yes
|
|
||||||
@test "--restore does not rewrite the list" (cat $HOME/.config/dot/packages/pacman | string collect) = "alpha
|
|
||||||
beta"
|
|
||||||
|
|
||||||
# --- --restore with no list yet is an error ---
|
|
||||||
set -gx HOME (mktemp -d)
|
|
||||||
dot init --url $remote >/dev/null 2>&1
|
|
||||||
mkdir -p $HOME/.config/dot/commands
|
|
||||||
cp $commands_dir/install.fish $HOME/.config/dot/commands/install.fish
|
|
||||||
set -gx PACMAN_LOG (mktemp)
|
|
||||||
|
|
||||||
dot install --restore >/dev/null 2>&1
|
|
||||||
set -l restore_no_list_status $status
|
|
||||||
|
|
||||||
@test "--restore fails when no package list exists yet" $restore_no_list_status -ne 0
|
|
||||||
|
|
||||||
# --- --restore and explicit packages are mutually exclusive ---
|
|
||||||
set -gx HOME (mktemp -d)
|
|
||||||
dot init --url $remote >/dev/null 2>&1
|
|
||||||
mkdir -p $HOME/.config/dot/commands
|
|
||||||
cp $commands_dir/install.fish $HOME/.config/dot/commands/install.fish
|
|
||||||
mkdir -p $HOME/.config/dot/packages
|
|
||||||
printf 'alpha\n' >$HOME/.config/dot/packages/pacman
|
|
||||||
set -gx PACMAN_LOG (mktemp)
|
|
||||||
|
|
||||||
dot install --restore extra >/dev/null 2>&1
|
|
||||||
set -l restore_conflict_status $status
|
|
||||||
set -l pacman_called_conflict (test -s $PACMAN_LOG; and echo yes; or echo no)
|
|
||||||
|
|
||||||
@test "--restore combined with package names fails" $restore_conflict_status -ne 0
|
|
||||||
@test "--restore combined with package names never calls pacman" $pacman_called_conflict = no
|
|
||||||
|
|
||||||
# --- help prints usage instead of touching pacman ---
|
|
||||||
set -gx HOME (mktemp -d)
|
|
||||||
dot init --url $remote >/dev/null 2>&1
|
|
||||||
mkdir -p $HOME/.config/dot/commands
|
|
||||||
cp $commands_dir/install.fish $HOME/.config/dot/commands/install.fish
|
|
||||||
set -gx PACMAN_LOG (mktemp)
|
|
||||||
|
|
||||||
set -l help_output (dot install help)
|
|
||||||
set -l help_status $status
|
|
||||||
set -l pacman_called_help (test -s $PACMAN_LOG; and echo yes; or echo no)
|
|
||||||
|
|
||||||
@test "dot install help succeeds" $help_status -eq 0
|
|
||||||
@test "dot install help mentions --restore" (string match -q '*--restore*' -- $help_output; echo $status) -eq 0
|
|
||||||
@test "dot install help mentions --no-sync" (string match -q '*--no-sync*' -- $help_output; echo $status) -eq 0
|
|
||||||
@test "dot install help never calls pacman" $pacman_called_help = no
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
# Migration findings: ~/wrk/dotfiles (old) → ~/.dotfiles (new)
|
|
||||||
|
|
||||||
Exploratory session comparing the archived i3/X11/bash dotfiles repo
|
|
||||||
(`~/wrk/dotfiles`, GitHub, 2023-2025) against the current bare-repo setup
|
|
||||||
(`~/.dotfiles`, Gitea, started 2026-07-03) on a fresh CachyOS + KDE Plasma
|
|
||||||
(Wayland) machine. Goal: not a literal port — for each old feature, decide
|
|
||||||
whether CachyOS/KDE already covers it for free (skip) or whether it needs an
|
|
||||||
equivalent tracked in the new repo (port). Nothing below has been
|
|
||||||
implemented yet; this is a planning doc only.
|
|
||||||
|
|
||||||
## Resolved — no action needed (already covered by KDE/CachyOS defaults)
|
|
||||||
|
|
||||||
- **Package management split** (old: `pacman.gui/nogui` + `aur.gui/nogui` +
|
|
||||||
Makefile/aurman installer). New `dot install` + flat `packages/pacman`
|
|
||||||
stays as-is — no plan to port the old list wholesale, just add packages as
|
|
||||||
needed.
|
|
||||||
- **Touchpad `xorg.conf`** — `kcminputrc`/System Settings has caused no
|
|
||||||
issues; not tracking it.
|
|
||||||
- **i3 tiling paradigm** (focus-by-direction, split/layout toggle, floating
|
|
||||||
toggle, resize, move-by-pixel, gaps, borders) — dropped entirely. No KWin
|
|
||||||
tiling script (Polonium/Bismuth/Krohnkite) installed or wanted; primary
|
|
||||||
tiling-like workflow now happens in tmux. KDE's native floating +
|
|
||||||
quick-tile (`Meta+Arrow`) is accepted as-is.
|
|
||||||
- **i3 workspace switch/move** (`Super+1-9,0` / `Super+Shift+1-9,0`) —
|
|
||||||
already matched by existing KDE defaults: `Meta+1-9` (Switch to Desktop
|
|
||||||
N), `Meta+Shift+1-9` i.e. `Meta+!/@/#/...` (Window to Desktop N).
|
|
||||||
- **Desktop count** — KDE has 9 virtual desktops configured; confirmed
|
|
||||||
sufficient, no change to 10.
|
|
||||||
- **Workspace/window assignment rules** (`assign firefox → ws1`, `plexamp →
|
|
||||||
ws10`) — skipped. `kwinrulesrc` stays empty for now.
|
|
||||||
- **Kill/reload/restart WM keys** — functionally covered by KDE defaults
|
|
||||||
(`Alt+F4` close, `Meta+Ctrl+Esc` kill window).
|
|
||||||
- **Terminal launch** (`Super+Return` → alacritty) — already identically
|
|
||||||
bound: `Meta+Return` → Alacritty, confirmed in `kglobalshortcutsrc`.
|
|
||||||
- **App launcher** (rofi) — superseded by KRunner (default `Alt+Space`/`Alt+F2`).
|
|
||||||
- **Media keys** (volume, mic mute, play/pause/next/prev) — already covered
|
|
||||||
natively via hardware key bindings, exceeding the old `wpctl`+`playerctl`
|
|
||||||
setup.
|
|
||||||
- **Brightness/backlight keys** — already covered via hardware
|
|
||||||
`Monitor Brightness Up/Down` bindings (powerdevil).
|
|
||||||
- **picom, Xresources, dracula color theme** — dropped. Not using dracula
|
|
||||||
going forward; kwin compositor replaces picom with no config needed.
|
|
||||||
- **rofi power menu** (lock/shutdown/restart/switch-user) — covered by
|
|
||||||
existing KDE defaults: `Meta+L` (Lock Session), `Ctrl+Alt+Del` (Show
|
|
||||||
Logout Screen = full power menu). *Note: `Meta+L` will be reassigned, see
|
|
||||||
below — Lock Session needs to move to `Meta+X`.*
|
|
||||||
- **polybar → Plasma panel** — nearly the entire module set already exists
|
|
||||||
as stock, **unmodified** Plasma panel defaults on this machine: workspace
|
|
||||||
indicator → Pager applet, battery → Battery applet (machine has `BAT0`),
|
|
||||||
backlight → Brightness applet, date/time → Digital Clock applet, volume →
|
|
||||||
system tray audio, now-playing → Media Controller applet (present, just
|
|
||||||
nothing to show — no MPRIS player installed currently). Only non-exact
|
|
||||||
match is polybar's centered window-title label (closest KDE equivalent is
|
|
||||||
the icon-only taskbar) — decided not worth adding a dedicated Window Title
|
|
||||||
applet. Whole row needs no tracking; see also the panel-layout finding
|
|
||||||
below (it's CachyOS's own shipped default, reproduces automatically).
|
|
||||||
|
|
||||||
## Resolved — needs porting (design agreed, not yet built)
|
|
||||||
|
|
||||||
- **Extra groups** (old: `.extra_groups` → `video`, `docker` via
|
|
||||||
`setup_users` in `bin/dot init`). Missing in new repo; not urgently needed
|
|
||||||
yet but a real gap.
|
|
||||||
- **Architecture decision**: new `dot` subcommand, e.g. `dot setup` (name
|
|
||||||
tentative), separate from `dot init`. `dot init` stays scoped to the
|
|
||||||
one-shot bootstrap (clone + checkout) and explicitly refuses to re-run;
|
|
||||||
`dot setup` is idempotent/re-runnable and is the new home for
|
|
||||||
machine-setup tasks (extra groups, folder layout, future ones), mirroring
|
|
||||||
the old `bin/dot init`'s `setup_users`/`setup_folders` sub-task split.
|
|
||||||
- **Folder naming / XDG dirs** (old: `setup_folders` renamed
|
|
||||||
`Desktop→.desktop`, `Documents→doc`, `Downloads→dwn`, `Music→mus`,
|
|
||||||
`Pictures→pic`, `Videos→vid`, `Templates/Public→.ignoreme`).
|
|
||||||
- **Decision**: restore the short-name convention (better for fish
|
|
||||||
autocompletion — shorter shared prefixes, e.g. `doc`/`dwn` only share one
|
|
||||||
character vs `Documents`/`Downloads`).
|
|
||||||
- Replace old `Projects`-style folder with **`wrk`** (matches the existing
|
|
||||||
`~/wrk` directory already in active use, e.g. `~/wrk/dotfiles`).
|
|
||||||
- `user-dirs.dirs` currently untracked and diverged (has full names +
|
|
||||||
an ad hoc `XDG_PROJECTS_DIR=$HOME/Projects` not in the old file at all).
|
|
||||||
Needs to be regenerated to the short-name convention (with `wrk`) and
|
|
||||||
then tracked, as part of the `dot setup` folders task.
|
|
||||||
- **Caps-lock/Escape swap** — user confirmed this needed manual
|
|
||||||
configuration (`kxkbrc`: `Options=caps:escape_shifted_capslock`), it is
|
|
||||||
**not** a KDE default. Needs tracking. No kcfg schema backs this setting —
|
|
||||||
it's a freeform string; "default" = the `Options=` line being absent
|
|
||||||
entirely. Simple to declare directly, no diffing tooling needed for this
|
|
||||||
one.
|
|
||||||
- **Screenshots** (old: `Print`/`Ctrl+Print`/`Shift+Print` via `maim`+`xclip`
|
|
||||||
→ `~/pic/screenshots/`, save + clipboard copy).
|
|
||||||
- **New keybinds**: `Meta+L` = full-screen capture, `Ctrl+Meta+L` = select
|
|
||||||
region, `Shift+Meta+L` = window capture — all via **Spectacle** (built-in
|
|
||||||
capture + clipboard; `maim`/`xclip` not needed, `xclip` isn't even
|
|
||||||
installed).
|
|
||||||
- **Consequence**: `Meta+L` is currently KDE's default Lock Session
|
|
||||||
shortcut — must be freed and Lock Session rebound to **`Meta+X`**.
|
|
||||||
- **Folder**: rename Spectacle's default save-folder name from
|
|
||||||
`Screenshots` (capital) to lowercase `screenshots`, matching the rest of
|
|
||||||
the short-folder convention.
|
|
||||||
- **Open detail**: exact Spectacle shortcut action IDs (likely
|
|
||||||
`FullScreenScreenShot`, `RectangularRegionScreenShot`,
|
|
||||||
`ActiveWindowScreenShot`) need to be verified via System Settings →
|
|
||||||
Shortcuts at implementation time — only `CurrentMonitorScreenShot` and
|
|
||||||
`OpenWithoutScreenshot` show up in the current `kglobalshortcutsrc` dump
|
|
||||||
(both unset), the others aren't customized yet so don't appear there.
|
|
||||||
|
|
||||||
## KDE config-tracking architecture (cross-cutting decision)
|
|
||||||
|
|
||||||
**Problem**: KDE rc files (`kxkbrc`, `kglobalshortcutsrc`, `kwinrc`,
|
|
||||||
`plasma-org.kde.plasma.desktop-appletsrc`, `kdeglobals`, etc.) mix real user
|
|
||||||
intent with large amounts of machine-specific/volatile noise (timestamps,
|
|
||||||
UUIDs, window state, plugin caches). Whole-file tracking (what naive
|
|
||||||
dotfiles repos do, e.g. `dnephin/dotfiles`) produces noisy diffs and risks
|
|
||||||
clobbering machine-specific state.
|
|
||||||
|
|
||||||
**Considered and rejected (for now)**: `chezmoi_modify_manager`-style
|
|
||||||
filtered source-of-truth + merge script. Powerful (tracks a minimal "intent"
|
|
||||||
INI fragment + per-file ignore/set rules, merges onto the live file), but
|
|
||||||
it's real tooling to build from scratch outside of chezmoi, and not
|
|
||||||
justified yet for the ~2 settings currently in scope.
|
|
||||||
|
|
||||||
**Decision**: track KDE settings as a declarative list of key/value pairs
|
|
||||||
applied imperatively via `kwriteconfig6`, run through `dot setup` (or
|
|
||||||
wherever machine-setup tasks land, see above) — not one-off hand-written
|
|
||||||
`kwriteconfig6` calls accumulating over time.
|
|
||||||
|
|
||||||
**Auto-detection tooling to build** (exploratory design only — not
|
|
||||||
implemented):
|
|
||||||
|
|
||||||
- **Command**: `dot config kde` — deliberately dispatchable, implies a
|
|
||||||
`dot config <target>` family with room for non-KDE targets later.
|
|
||||||
- **Language**: Python 3 (already installed) for XML/kcfg parsing — nested
|
|
||||||
`<group>`/`<entry>`/`<default>` structures are painful to parse in
|
|
||||||
fish/`xmllint` one-liners. Invoked from a fish wrapper
|
|
||||||
(`~/.config/dot/commands/config.fish` → `_dot_config` → sub-dispatch to
|
|
||||||
KDE logic), following the existing help-then-argparse /
|
|
||||||
`_dot_<name>_usage` convention.
|
|
||||||
- **Coverage**: as broad as possible across known KDE rc files, not just
|
|
||||||
`kwinrc` — files/settings with nothing customized are expected to return
|
|
||||||
empty, that's fine.
|
|
||||||
- **Per-file-type handling** (three different mechanisms, no single
|
|
||||||
approach covers everything):
|
|
||||||
1. **`kglobalshortcutsrc`** — self-describing, no schema needed. Each line
|
|
||||||
is `Action=Current,Default,FriendlyName`; diff field 1 vs field 2,
|
|
||||||
report only mismatches. Fully automatic.
|
|
||||||
2. **KConfigXT schema-backed settings** — real schemas exist at
|
|
||||||
`/usr/share/config.kcfg/*.kcfg` (41 files on this machine) with
|
|
||||||
`<default>` tags per `<entry>` inside `<group>` blocks. **Caveat**: not
|
|
||||||
every kcfg statically declares its target rc file — some use
|
|
||||||
`<kcfgfile arg="true" />` (e.g. `kwin.kcfg`), meaning the target file is
|
|
||||||
supplied at runtime by the owning app, not in the XML. Full automatic
|
|
||||||
discovery isn't possible in all cases; a curated `(rcfile → [kcfg
|
|
||||||
files])` mapping table needs to be hardcoded from domain knowledge —
|
|
||||||
confirmed acceptable. Known `kwinrc` mapping so far:
|
|
||||||
`virtualdesktopssettings.kcfg`, `kwindecorationsettings.kcfg`,
|
|
||||||
`workspaceoptions_kwinsettings.kcfg`, several accessibility kcfg files,
|
|
||||||
plus `kwin.kcfg` itself (needs manual mapping, `arg="true"`). For each
|
|
||||||
entry: read the live value via
|
|
||||||
`kreadconfig6 --file <rcfile> --group <group> --key <key>` and compare
|
|
||||||
against the schema's `<default>`.
|
|
||||||
3. **Freeform/schema-less string settings** (e.g. `kxkbrc`'s `Options=`
|
|
||||||
line) — no kcfg exists; "default" simply means the key/line is absent.
|
|
||||||
Trivial, no diffing tooling needed, just declare directly.
|
|
||||||
4. **Plasma panel layout** (`plasma-org.kde.plasma.desktop-appletsrc`) —
|
|
||||||
not schema-based at all; generated once from a shipped `layout.js`.
|
|
||||||
Confirmed CachyOS ships its **own** look-and-feel/layout
|
|
||||||
(`/usr/share/plasma/look-and-feel/CachyOS-Nord/`), not vanilla KDE
|
|
||||||
Breeze — so the current panel *is* "default" by construction and
|
|
||||||
reproduces automatically on any fresh CachyOS install. No tracking
|
|
||||||
needed, no diffing possible/necessary.
|
|
||||||
5. **Empirical fallback** (not needed yet, noted for completeness): for
|
|
||||||
anything not covered by the above, spin up a scratch
|
|
||||||
`HOME`/`XDG_CONFIG_HOME`, let the app initialize its config fresh, diff
|
|
||||||
against the real file.
|
|
||||||
|
|
||||||
**Status**: design only, per explicit instruction — do not implement until
|
|
||||||
asked.
|
|
||||||
|
|
||||||
## Also noted, not yet actioned
|
|
||||||
|
|
||||||
- Once any of the above keybind changes are actually made (screenshot
|
|
||||||
rebinds, `Meta+L`→`Meta+X` lock move, etc.), remember the project's own
|
|
||||||
convention: add/update rows in `~/.github/keybindings.md` for each changed
|
|
||||||
keybind, per `~/.config/dot/CLAUDE.md`.
|
|
||||||
|
|
||||||
## dot voice — hands-free dictation (shelved, 2026-07-04)
|
|
||||||
|
|
||||||
Built and then fully reverted a `dot voice` subcommand for hands-free
|
|
||||||
dictation into Claude Code: local Silero VAD (torch-free, onnxruntime
|
|
||||||
only) segmenting mic audio into utterances, each POSTed to a
|
|
||||||
`whisper-server` instance running remotely on a Proxmox host with an
|
|
||||||
NVIDIA GPU (Vulkan backend), transcribed text buffered at the cursor via
|
|
||||||
`ydotool`, submitted on a spoken "send it" and cancelled on "scratch
|
|
||||||
that".
|
|
||||||
Iterated through several accuracy levers in one session: dropped then
|
|
||||||
restored a literal-vocabulary `initial_prompt` (helped once on the
|
|
||||||
stronger model), a deterministic post-transcription `replacements.txt`
|
|
||||||
for persistent single-word misses, per-request `temperature`/
|
|
||||||
`temperature_inc`, beam search (`-bs`/`-bo`, ruled out as a factor),
|
|
||||||
and a quantization bump from `q5_0` to full fp16 `medium.en` (helped
|
|
||||||
substantially).
|
|
||||||
Also fixed a real bug along the way: whisper-server can return multi-
|
|
||||||
segment text joined by newlines, and `ydotool type` sends an embedded
|
|
||||||
`\n` as a literal Enter keypress — this was silently submitting partial
|
|
||||||
dictation mid-sentence. Fixed by collapsing all whitespace before typing.
|
|
||||||
Despite all of that, real-world accuracy over the laptop's built-in mic
|
|
||||||
was still not good enough for daily use — small word-substitution and
|
|
||||||
dropped-word errors persisted even with the best config found (fp16,
|
|
||||||
no beam search, prompt restored).
|
|
||||||
**Shelved reason**: audio input quality was the one variable never
|
|
||||||
tested — everything tuned this session was server/decoding-side. The
|
|
||||||
user's only better-microphone option is their desktop, which doesn't
|
|
||||||
have this dotfiles setup yet.
|
|
||||||
**If resumed**: test with a real microphone (headset/USB) before any
|
|
||||||
further server-side tuning — it's suspected to matter more than any of
|
|
||||||
the software changes made so far. Also worth trying `large-v3-turbo`
|
|
||||||
given the Proxmox GPU had comfortable headroom even at fp16 `medium.en`
|
|
||||||
(~0.1–0.5s per utterance).
|
|
||||||
**State**: fully reverted, nothing left in the tree or installed
|
|
||||||
packages list. The full implementation existed as local commit
|
|
||||||
`745e417f19a02bc589c5b32853e629993adaa01f` ("dotcli: Voice dictation
|
|
||||||
software using whisper"), never pushed, then hard-reset away — not
|
|
||||||
recoverable via normal git history, only via reflog for a limited time
|
|
||||||
if urgently needed. The KDE global shortcut (`Meta+Ctrl+Space` → `dot
|
|
||||||
voice arm`) was configured in System Settings and was **not** undone by
|
|
||||||
this revert — check System Settings → Shortcuts → Custom Shortcuts if
|
|
||||||
this work is ever picked back up or fully abandoned.
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
function __dot_custom_subcommands
|
|
||||||
echo init
|
|
||||||
echo help
|
|
||||||
path basename $HOME/.config/dot/commands/*.fish 2>/dev/null | path change-extension ''
|
|
||||||
end
|
|
||||||
|
|
||||||
complete -c dot -n __fish_use_subcommand -a "(__dot_custom_subcommands)"
|
|
||||||
|
|
||||||
# --- dot install ---
|
|
||||||
complete -c dot -n "__fish_seen_subcommand_from install; and not __fish_seen_argument -l restore" -l restore -d "reinstall every package from the saved list"
|
|
||||||
complete -c dot -n "__fish_seen_subcommand_from install; and not __fish_seen_argument -l no-sync" -l no-sync -d "skip the pacman -Sy database refresh"
|
|
||||||
complete -c dot -n "__fish_seen_subcommand_from install; and not __fish_seen_argument -l restore" -f -a "(__fish_print_pacman_packages)"
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
complete --command fishtape --short v --long version --description "Print version"
|
|
||||||
complete --command fishtape --short h --long help --description "Print help"
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
alias cp='cp -v'
|
|
||||||
alias vi=nvim
|
|
||||||
alias vim=nvim
|
|
||||||
alias tmx='tmux new-session -A -s'
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
source /usr/share/cachyos-fish-config/cachyos-config.fish
|
|
||||||
|
|
||||||
set -gx EDITOR nvim
|
|
||||||
set -gx VISUAL nvim
|
|
||||||
|
|
||||||
# overwrite greeting
|
|
||||||
# potentially disabling fastfetch
|
|
||||||
#function fish_greeting
|
|
||||||
# # smth smth
|
|
||||||
#end
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
jorgebucaran/fishtape
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
# This file contains fish universal variable definitions.
|
|
||||||
# VERSION: 3.0
|
|
||||||
SETUVAR __done_min_cmd_duration:10000
|
|
||||||
SETUVAR __done_notification_urgency_level:low
|
|
||||||
SETUVAR _fisher_jorgebucaran_2F_fishtape_files:\x7e/\x2econfig/fish/functions/fishtape\x2efish\x1e\x7e/\x2econfig/fish/completions/fishtape\x2efish
|
|
||||||
SETUVAR _fisher_plugins:jorgebucaran/fishtape
|
|
||||||
SETUVAR _fisher_upgraded_to_4_4:\x1d
|
|
||||||
SETUVAR pure_begin_prompt_with_current_directory:true
|
|
||||||
SETUVAR pure_check_for_new_release:false
|
|
||||||
SETUVAR pure_color_at_sign:pure_color_mute
|
|
||||||
SETUVAR pure_color_aws_profile:pure_color_warning
|
|
||||||
SETUVAR pure_color_command_duration:pure_color_warning
|
|
||||||
SETUVAR pure_color_current_directory:pure_color_primary
|
|
||||||
SETUVAR pure_color_danger:red
|
|
||||||
SETUVAR pure_color_dark:black
|
|
||||||
SETUVAR pure_color_exit_status:pure_color_danger
|
|
||||||
SETUVAR pure_color_git_branch:pure_color_mute
|
|
||||||
SETUVAR pure_color_git_dirty:pure_color_mute
|
|
||||||
SETUVAR pure_color_git_stash:pure_color_info
|
|
||||||
SETUVAR pure_color_git_unpulled_commits:pure_color_info
|
|
||||||
SETUVAR pure_color_git_unpushed_commits:pure_color_info
|
|
||||||
SETUVAR pure_color_hostname:pure_color_mute
|
|
||||||
SETUVAR pure_color_info:cyan
|
|
||||||
SETUVAR pure_color_jobs:pure_color_normal
|
|
||||||
SETUVAR pure_color_k8s_context:pure_color_success
|
|
||||||
SETUVAR pure_color_k8s_namespace:pure_color_primary
|
|
||||||
SETUVAR pure_color_k8s_prefix:pure_color_info
|
|
||||||
SETUVAR pure_color_light:white
|
|
||||||
SETUVAR pure_color_mute:brblack
|
|
||||||
SETUVAR pure_color_nixdevshell_prefix:pure_color_info
|
|
||||||
SETUVAR pure_color_nixdevshell_symbol:pure_color_mute
|
|
||||||
SETUVAR pure_color_normal:normal
|
|
||||||
SETUVAR pure_color_prefix_root_prompt:pure_color_danger
|
|
||||||
SETUVAR pure_color_primary:blue
|
|
||||||
SETUVAR pure_color_prompt_on_error:pure_color_danger
|
|
||||||
SETUVAR pure_color_prompt_on_success:pure_color_success
|
|
||||||
SETUVAR pure_color_success:magenta
|
|
||||||
SETUVAR pure_color_system_time:pure_color_mute
|
|
||||||
SETUVAR pure_color_username_normal:pure_color_mute
|
|
||||||
SETUVAR pure_color_username_root:pure_color_light
|
|
||||||
SETUVAR pure_color_virtualenv:pure_color_mute
|
|
||||||
SETUVAR pure_color_warning:yellow
|
|
||||||
SETUVAR pure_convert_exit_status_to_signal:false
|
|
||||||
SETUVAR pure_enable_aws_profile:true
|
|
||||||
SETUVAR pure_enable_container_detection:true
|
|
||||||
SETUVAR pure_enable_git:true
|
|
||||||
SETUVAR pure_enable_k8s:false
|
|
||||||
SETUVAR pure_enable_nixdevshell:false
|
|
||||||
SETUVAR pure_enable_single_line_prompt:false
|
|
||||||
SETUVAR pure_enable_virtualenv:true
|
|
||||||
SETUVAR pure_reverse_prompt_symbol_in_vimode:true
|
|
||||||
SETUVAR pure_separate_prompt_on_error:false
|
|
||||||
SETUVAR pure_shorten_prompt_current_directory_length:0
|
|
||||||
SETUVAR pure_shorten_window_title_current_directory_length:0
|
|
||||||
SETUVAR pure_show_exit_status:false
|
|
||||||
SETUVAR pure_show_jobs:false
|
|
||||||
SETUVAR pure_show_numbered_git_indicator:false
|
|
||||||
SETUVAR pure_show_prefix_root_prompt:false
|
|
||||||
SETUVAR pure_show_subsecond_command_duration:false
|
|
||||||
SETUVAR pure_show_system_time:false
|
|
||||||
SETUVAR pure_symbol_aws_profile_prefix:
|
|
||||||
SETUVAR pure_symbol_container_prefix:
|
|
||||||
SETUVAR pure_symbol_exit_status_prefix:\x7c
|
|
||||||
SETUVAR pure_symbol_exit_status_separator:\x7c
|
|
||||||
SETUVAR pure_symbol_git_dirty:\x2a
|
|
||||||
SETUVAR pure_symbol_git_stash:\u2261
|
|
||||||
SETUVAR pure_symbol_git_unpulled_commits:\u21e3
|
|
||||||
SETUVAR pure_symbol_git_unpushed_commits:\u21e1
|
|
||||||
SETUVAR pure_symbol_k8s_prefix:\u2638
|
|
||||||
SETUVAR pure_symbol_nixdevshell_prefix:\u2744\ufe0f
|
|
||||||
SETUVAR pure_symbol_prefix_root_prompt:\x23
|
|
||||||
SETUVAR pure_symbol_prompt:\u276f
|
|
||||||
SETUVAR pure_symbol_reverse_prompt:\u276e
|
|
||||||
SETUVAR pure_symbol_ssh_prefix:
|
|
||||||
SETUVAR pure_symbol_title_bar_separator:\x2d
|
|
||||||
SETUVAR pure_symbol_virtualenv_prefix:
|
|
||||||
SETUVAR pure_system_time_format:\x2b\x25T
|
|
||||||
SETUVAR pure_threshold_command_duration:5
|
|
||||||
SETUVAR pure_truncate_prompt_current_directory_keeps:\x2d1
|
|
||||||
SETUVAR pure_truncate_window_title_current_directory_keeps:\x2d1
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
function dot --wraps=git --description 'Manage dotfiles via a bare repo checked out over $HOME'
|
|
||||||
set -l dotfiles_dir $HOME/.dotfiles
|
|
||||||
|
|
||||||
if test "$argv[1]" = init
|
|
||||||
set -e argv[1]
|
|
||||||
__dot_init $dotfiles_dir $argv
|
|
||||||
return $status
|
|
||||||
end
|
|
||||||
|
|
||||||
if test "$argv[1]" = help
|
|
||||||
__dot_help
|
|
||||||
return $status
|
|
||||||
end
|
|
||||||
|
|
||||||
set -l commands_dir $HOME/.config/dot/commands
|
|
||||||
set -l command_file $commands_dir/$argv[1].fish
|
|
||||||
|
|
||||||
if test -n "$argv[1]" -a -f "$command_file"
|
|
||||||
source $command_file
|
|
||||||
_dot_$argv[1] $argv[2..-1]
|
|
||||||
return $status
|
|
||||||
end
|
|
||||||
|
|
||||||
git --git-dir=$dotfiles_dir --work-tree=$HOME $argv
|
|
||||||
end
|
|
||||||
|
|
||||||
# Kept inline (not a separate autoloaded function file) because this is the
|
|
||||||
# only subcommand that must work before the dotfiles repo has been cloned.
|
|
||||||
function __dot_init
|
|
||||||
set -l dotfiles_dir $argv[1]
|
|
||||||
set -e argv[1]
|
|
||||||
|
|
||||||
argparse 'url=' -- $argv
|
|
||||||
or return 1
|
|
||||||
|
|
||||||
set -l url $_flag_url
|
|
||||||
test -n "$url"; or set url ssh://gitea@git.alexion.dev:2022/alexion/dotfiles.git
|
|
||||||
|
|
||||||
if test -e $dotfiles_dir
|
|
||||||
echo "dot init: $dotfiles_dir already exists, refusing to re-initialize" >&2
|
|
||||||
return 1
|
|
||||||
end
|
|
||||||
|
|
||||||
git clone --bare $url $dotfiles_dir
|
|
||||||
or begin
|
|
||||||
echo "dot init: failed to clone $url" >&2
|
|
||||||
return 1
|
|
||||||
end
|
|
||||||
|
|
||||||
git --git-dir=$dotfiles_dir config status.showUntrackedFiles no
|
|
||||||
|
|
||||||
set -l checkout_output (git --git-dir=$dotfiles_dir --work-tree=$HOME checkout 2>&1)
|
|
||||||
set -l checkout_status $status
|
|
||||||
|
|
||||||
if test $checkout_status -ne 0
|
|
||||||
set -l conflicts
|
|
||||||
set -l in_block 0
|
|
||||||
|
|
||||||
for line in $checkout_output
|
|
||||||
if test $in_block -eq 1
|
|
||||||
if string match -rq '^\s' -- $line
|
|
||||||
set -a conflicts (string trim -- $line)
|
|
||||||
continue
|
|
||||||
else
|
|
||||||
set in_block 0
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
string match -q '*would be overwritten by checkout:*' -- $line
|
|
||||||
and set in_block 1
|
|
||||||
end
|
|
||||||
|
|
||||||
if test (count $conflicts) -eq 0
|
|
||||||
echo "dot init: checkout failed and no recoverable conflicts were found:" >&2
|
|
||||||
printf '%s\n' $checkout_output >&2
|
|
||||||
return 1
|
|
||||||
end
|
|
||||||
|
|
||||||
set -l backup_dir $HOME/.dotfiles-backup/(date +%Y%m%dT%H%M%S)
|
|
||||||
for f in $conflicts
|
|
||||||
mkdir -p (path dirname $backup_dir/$f)
|
|
||||||
mv $HOME/$f $backup_dir/$f
|
|
||||||
echo "dot init: backed up ~/$f to $backup_dir/$f"
|
|
||||||
end
|
|
||||||
|
|
||||||
git --git-dir=$dotfiles_dir --work-tree=$HOME checkout
|
|
||||||
or begin
|
|
||||||
echo "dot init: checkout still failing after backing up conflicts, aborting" >&2
|
|
||||||
return 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
echo "dot init: bootstrapped $dotfiles_dir from $url"
|
|
||||||
end
|
|
||||||
|
|
||||||
# The custom-subcommand glob is duplicated (not shared with
|
|
||||||
# completions/dot.fish) because fish only autoloads a function from a file
|
|
||||||
# named after that function; a shared helper would go undefined if `dot help`
|
|
||||||
# ran in a completion context before `dot` itself had ever been sourced.
|
|
||||||
function __dot_help
|
|
||||||
echo "dot: manage dotfiles via a bare repo checked out over \$HOME
|
|
||||||
|
|
||||||
Commands:
|
|
||||||
init bootstrap the dotfiles repo on a new machine
|
|
||||||
help show this message"
|
|
||||||
|
|
||||||
for f in $HOME/.config/dot/commands/*.fish
|
|
||||||
test -e $f; or continue
|
|
||||||
echo " "(path basename $f | path change-extension '')
|
|
||||||
end
|
|
||||||
|
|
||||||
echo "
|
|
||||||
Run 'dot <command> help' for flags on a specific command.
|
|
||||||
|
|
||||||
Any other command is passed through to git (dot status, dot add, dot commit, dot push, ...)."
|
|
||||||
end
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
function fishtape --description "Test scripts, functions, and plugins in Fish"
|
|
||||||
switch "$argv"
|
|
||||||
case -v --version
|
|
||||||
echo "fishtape, version 3.0.1"
|
|
||||||
case "" -h --help
|
|
||||||
echo "Usage: fishtape <files ...> Run test files"
|
|
||||||
echo "Options:"
|
|
||||||
echo " -v or --version Print version"
|
|
||||||
echo " -h or --help Print this help message"
|
|
||||||
case \*
|
|
||||||
set --local files (realpath $argv)
|
|
||||||
|
|
||||||
for file in $files
|
|
||||||
if test ! -f $file
|
|
||||||
echo "fishtape: Invalid file or file not found: \"$file\"" >&2
|
|
||||||
return 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
set --local operators -{n,z,b,c,d,e,f,g,G,k,L,O,p,r,s,S,t,u,w,x}
|
|
||||||
set --local expectations \
|
|
||||||
"a non-zero length string" \
|
|
||||||
"a zero length string" \
|
|
||||||
"a block device" \
|
|
||||||
"a character device" \
|
|
||||||
"a directory" \
|
|
||||||
"an existing file" \
|
|
||||||
"a regular file" \
|
|
||||||
"a file with the set-group-ID bit set" \
|
|
||||||
"a file with same group ID as the current user" \
|
|
||||||
"a file with the sticky bit set" \
|
|
||||||
"a symbolic link" \
|
|
||||||
"a file owned by the current user" \
|
|
||||||
"a named pipe" \
|
|
||||||
"a file marked as readable" \
|
|
||||||
"a file of size greater than zero" \
|
|
||||||
"a socket" \
|
|
||||||
"a terminal tty file descriptor" \
|
|
||||||
"a file with the set-user-ID bit set" \
|
|
||||||
"a file marked as writable" \
|
|
||||||
"a file marked as executable"
|
|
||||||
|
|
||||||
set --universal _fishtape_test_number 0
|
|
||||||
set --universal _fishtape_test_passed 0
|
|
||||||
set --universal _fishtape_test_failed 0
|
|
||||||
|
|
||||||
function @echo
|
|
||||||
echo "# $argv"
|
|
||||||
end
|
|
||||||
|
|
||||||
function @test --argument-names name --inherit-variable operators --inherit-variable expectations
|
|
||||||
set --erase argv[1]
|
|
||||||
set --query argv[2] || set --append argv ""
|
|
||||||
|
|
||||||
set _fishtape_test_number (math $_fishtape_test_number + 1)
|
|
||||||
|
|
||||||
if test $argv
|
|
||||||
set _fishtape_test_passed (math $_fishtape_test_passed + 1)
|
|
||||||
|
|
||||||
echo "ok $_fishtape_test_number $name"
|
|
||||||
else
|
|
||||||
if test $argv[1] = "!"
|
|
||||||
set operator "! "
|
|
||||||
set expected "not "
|
|
||||||
set --erase argv[1]
|
|
||||||
end
|
|
||||||
|
|
||||||
if set --query argv[3]
|
|
||||||
set operator "$operator"$argv[2]
|
|
||||||
set expected (string escape -- $argv[3])
|
|
||||||
set actual (string escape -- $argv[1])
|
|
||||||
else
|
|
||||||
set operator "$operator"$argv[1]
|
|
||||||
set expected "$expected"$expectations[(contains --index -- $argv[1] $operators)]
|
|
||||||
set actual (string escape -- $argv[2])
|
|
||||||
end
|
|
||||||
|
|
||||||
set _fishtape_test_failed (math $_fishtape_test_failed + 1)
|
|
||||||
|
|
||||||
status print-stack-trace |
|
|
||||||
string replace --filter --regex -- "\s+called on line (\d+) of file (.+)" '$2:$1' |
|
|
||||||
read --local at
|
|
||||||
|
|
||||||
echo "not ok $_fishtape_test_number $name"
|
|
||||||
echo " ---"
|
|
||||||
echo " operator: $operator"
|
|
||||||
echo " expected: $expected"
|
|
||||||
echo " actual: $actual"
|
|
||||||
echo " at: $at"
|
|
||||||
echo " ..."
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
echo TAP version 13
|
|
||||||
|
|
||||||
for file in $files
|
|
||||||
fish --init-command=(functions @echo | string collect) --init-command=(functions @test | string collect) $file
|
|
||||||
end
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "1..$_fishtape_test_number"
|
|
||||||
echo "# pass $_fishtape_test_passed"
|
|
||||||
test $_fishtape_test_failed -eq 0 &&
|
|
||||||
echo "# ok" ||
|
|
||||||
echo "# fail $_fishtape_test_failed"
|
|
||||||
|
|
||||||
functions --erase @echo @test
|
|
||||||
|
|
||||||
set --local failed $_fishtape_test_failed
|
|
||||||
set --erase _fishtape_test_number
|
|
||||||
set --erase _fishtape_test_passed
|
|
||||||
set --erase _fishtape_test_failed
|
|
||||||
|
|
||||||
test $failed -eq 0
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
vim.opt_local.conceallevel = 2
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
require("config.options")
|
|
||||||
require("config.keymaps")
|
|
||||||
require("config.lazy")
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"lazy.nvim": { "branch": "main", "commit": "306a05526ada86a7b30af95c5cc81ffba93fef97" },
|
|
||||||
"nord.nvim": { "branch": "main", "commit": "87394d4fc35c901bbe38326a78d31ab1ead826b6" },
|
|
||||||
"nvim-treesitter": { "branch": "master", "commit": "cf12346a3414fa1b06af75c79faebe7f76df080a" },
|
|
||||||
"render-markdown.nvim": { "branch": "main", "commit": "f422cb5c6855f150e2ddcfaf44e7157b98b34f6a" }
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
local map = vim.keymap.set
|
|
||||||
|
|
||||||
map("n", "<C-h>", "<C-w>h", { desc = "Move focus left" })
|
|
||||||
map("n", "<C-j>", "<C-w>j", { desc = "Move focus down" })
|
|
||||||
map("n", "<C-k>", "<C-w>k", { desc = "Move focus up" })
|
|
||||||
map("n", "<C-l>", "<C-w>l", { desc = "Move focus right" })
|
|
||||||
|
|
||||||
map("n", "<Esc>", "<cmd>nohlsearch<CR>", { desc = "Clear search highlight" })
|
|
||||||
|
|
||||||
map("n", "<leader>e", "<cmd>Lexplore<CR>", { desc = "Toggle file explorer" })
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
|
|
||||||
if not vim.uv.fs_stat(lazypath) then
|
|
||||||
local lazyrepo = "https://github.com/folke/lazy.nvim.git"
|
|
||||||
local out = vim.fn.system({ "git", "clone", "--filter=blob:none", "--branch=stable", lazyrepo, lazypath })
|
|
||||||
if vim.v.shell_error ~= 0 then
|
|
||||||
vim.api.nvim_echo({
|
|
||||||
{ "Failed to clone lazy.nvim:\n", "ErrorMsg" },
|
|
||||||
{ out, "WarningMsg" },
|
|
||||||
{ "\nPress any key to exit..." },
|
|
||||||
}, true, {})
|
|
||||||
vim.fn.getchar()
|
|
||||||
os.exit(1)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
vim.opt.rtp:prepend(lazypath)
|
|
||||||
|
|
||||||
require("lazy").setup({
|
|
||||||
spec = {
|
|
||||||
{ import = "plugins" },
|
|
||||||
},
|
|
||||||
install = { colorscheme = { "nord" } },
|
|
||||||
checker = { enabled = false },
|
|
||||||
})
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
vim.g.mapleader = " "
|
|
||||||
|
|
||||||
local opt = vim.opt
|
|
||||||
|
|
||||||
-- Clipboard: use neovim's built-in OSC 52 provider, no external binary needed.
|
|
||||||
vim.g.clipboard = "osc52"
|
|
||||||
opt.clipboard = "unnamedplus"
|
|
||||||
|
|
||||||
opt.number = true
|
|
||||||
opt.relativenumber = true
|
|
||||||
|
|
||||||
opt.shiftwidth = 2
|
|
||||||
opt.tabstop = 2
|
|
||||||
opt.expandtab = true
|
|
||||||
|
|
||||||
opt.mouse = "a"
|
|
||||||
|
|
||||||
opt.undofile = true
|
|
||||||
|
|
||||||
opt.ignorecase = true
|
|
||||||
opt.smartcase = true
|
|
||||||
|
|
||||||
opt.splitright = true
|
|
||||||
opt.splitbelow = true
|
|
||||||
|
|
||||||
opt.wrap = false
|
|
||||||
|
|
||||||
opt.scrolloff = 8
|
|
||||||
opt.cursorline = true
|
|
||||||
|
|
||||||
vim.g.netrw_banner = 0
|
|
||||||
vim.g.netrw_liststyle = 3
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
return {
|
|
||||||
"gbprod/nord.nvim",
|
|
||||||
lazy = false,
|
|
||||||
priority = 1000,
|
|
||||||
opts = {
|
|
||||||
transparent = true,
|
|
||||||
},
|
|
||||||
config = function(_, opts)
|
|
||||||
require("nord").setup(opts)
|
|
||||||
vim.cmd.colorscheme("nord")
|
|
||||||
end,
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
return {
|
|
||||||
"MeanderingProgrammer/render-markdown.nvim",
|
|
||||||
ft = { "markdown" },
|
|
||||||
dependencies = { "nvim-treesitter/nvim-treesitter" },
|
|
||||||
opts = {},
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
return {
|
|
||||||
"nvim-treesitter/nvim-treesitter",
|
|
||||||
branch = "master",
|
|
||||||
build = ":TSUpdate",
|
|
||||||
opts = {
|
|
||||||
ensure_installed = {
|
|
||||||
"markdown",
|
|
||||||
"markdown_inline",
|
|
||||||
"lua",
|
|
||||||
"bash",
|
|
||||||
"fish",
|
|
||||||
"rust",
|
|
||||||
"javascript",
|
|
||||||
"typescript",
|
|
||||||
"java",
|
|
||||||
"kotlin",
|
|
||||||
"c",
|
|
||||||
"cpp",
|
|
||||||
"html",
|
|
||||||
"css",
|
|
||||||
"python",
|
|
||||||
},
|
|
||||||
auto_install = false,
|
|
||||||
highlight = { enable = true },
|
|
||||||
indent = { enable = true },
|
|
||||||
},
|
|
||||||
config = function(_, opts)
|
|
||||||
require("nvim-treesitter.configs").setup(opts)
|
|
||||||
end,
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
# Prefix: Ctrl-Space. Chosen over Ctrl-b (awkward reach) and Ctrl-a (collides
|
|
||||||
# with readline's beginning-of-line, which fights editing text in shells and
|
|
||||||
# in Claude Code's prompt). Verified clear of IME/KDE/Claude Code bindings.
|
|
||||||
unbind C-b
|
|
||||||
set -g prefix C-Space
|
|
||||||
bind C-Space send-prefix
|
|
||||||
|
|
||||||
set -g mouse on
|
|
||||||
# OSC52 lets copy-mode selections land in the system clipboard via the
|
|
||||||
# terminal itself (Alacritty supports it) -- no wl-copy/xclip needed, and it
|
|
||||||
# still works over SSH later since the escape sequence travels with the data.
|
|
||||||
set -g set-clipboard on
|
|
||||||
|
|
||||||
set -g mode-keys vi
|
|
||||||
set -g status-keys vi
|
|
||||||
bind -T copy-mode-vi v send -X begin-selection
|
|
||||||
bind -T copy-mode-vi y send -X copy-selection-and-cancel
|
|
||||||
bind -T copy-mode-vi MouseDragEnd1Pane send -X copy-selection-and-cancel
|
|
||||||
|
|
||||||
# tmux's -h/-v split flags name the *arrangement*, not the divider line, which
|
|
||||||
# is backwards from how the divider looks -- so pick keys by what they draw:
|
|
||||||
# \ draws a side-by-side split (vertical line), - draws a stacked split
|
|
||||||
# (horizontal line). Unshifted versions of |/- since splitting is frequent.
|
|
||||||
unbind %
|
|
||||||
unbind '"'
|
|
||||||
bind \\ split-window -h -c "#{pane_current_path}"
|
|
||||||
bind - split-window -v -c "#{pane_current_path}"
|
|
||||||
bind c new-window -c "#{pane_current_path}"
|
|
||||||
|
|
||||||
bind h select-pane -L
|
|
||||||
bind j select-pane -D
|
|
||||||
bind k select-pane -U
|
|
||||||
bind l select-pane -R
|
|
||||||
|
|
||||||
set -g base-index 1
|
|
||||||
setw -g pane-base-index 1
|
|
||||||
set -g renumber-windows on
|
|
||||||
|
|
||||||
bind r source-file ~/.config/tmux/tmux.conf \; display-message "tmux.conf reloaded"
|
|
||||||
|
|
||||||
# True color passthrough. ",*" (rather than naming Alacritty's xterm-256color
|
|
||||||
# specifically) so this keeps working if the terminal emulator changes later.
|
|
||||||
set -g default-terminal "tmux-256color"
|
|
||||||
set -ag terminal-overrides ",*:RGB"
|
|
||||||
|
|
||||||
# Default 500ms delay on Esc exists to disambiguate meta-key sequences; it
|
|
||||||
# reads as noticeable lag exiting insert mode in neovim, so drop it.
|
|
||||||
set -sg escape-time 10
|
|
||||||
|
|
||||||
set -g history-limit 10000
|
|
||||||
|
|
||||||
# Flag a background window in the status bar when its Claude Code session
|
|
||||||
# rings the terminal bell (permission prompt / task done while unfocused).
|
|
||||||
# bell-action=none stops tmux from ever passing the actual BEL through to
|
|
||||||
# Alacritty (no beep, no flash) -- monitor-bell's per-window tracking for the
|
|
||||||
# status-line highlight is independent of that and keeps working.
|
|
||||||
setw -g monitor-bell on
|
|
||||||
set -g bell-action none
|
|
||||||
|
|
||||||
# Minimal status bar (session + window list only), styled to match the Nord
|
|
||||||
# theme already used in alacritty.toml.
|
|
||||||
set -g status-position bottom
|
|
||||||
set -g status-style "bg=#2E3440,fg=#D8DEE9"
|
|
||||||
set -g status-left " #S "
|
|
||||||
set -g status-left-length 20
|
|
||||||
set -g status-right ""
|
|
||||||
setw -g window-status-current-style "bg=#88C0D0,fg=#2E3440,bold"
|
|
||||||
setw -g window-status-current-format " #I:#W "
|
|
||||||
setw -g window-status-format " #I:#W "
|
|
||||||
setw -g window-status-style "fg=#4C566A"
|
|
||||||
setw -g window-status-bell-style "bg=#BF616A,fg=#2E3440,bold"
|
|
||||||
|
|
||||||
set -g pane-border-style "fg=#3B4252"
|
|
||||||
set -g pane-active-border-style "fg=#88C0D0"
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
[user]
|
|
||||||
name = alexion
|
|
||||||
email = contact@alexion.dev
|
|
||||||
31
.github/README.md
vendored
31
.github/README.md
vendored
@@ -1,31 +0,0 @@
|
|||||||
# dotfiles
|
|
||||||
|
|
||||||
Dotfiles managed as a bare git repo checked out over `$HOME`, for machines
|
|
||||||
running CachyOS with KDE Plasma.
|
|
||||||
|
|
||||||
## Bootstrapping a new machine
|
|
||||||
|
|
||||||
```sh
|
|
||||||
mkdir -p ~/.config/fish/functions
|
|
||||||
curl -fsSL https://git.alexion.dev/alexion/dotfiles/raw/branch/main/.config/fish/functions/dot.fish \
|
|
||||||
-o ~/.config/fish/functions/dot.fish
|
|
||||||
fish -c 'dot init'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
| ----------------------- | ----------------------------------------------------------------------------------------- |
|
|
||||||
| `dot help` | Lists available commands. |
|
|
||||||
| `dot init` | Bootstraps the dotfiles repo on a new machine. |
|
|
||||||
| `dot install <pkgs>` | Installs the given pacman packages and appends them to the tracked list (`~/.config/dot/packages/pacman`). |
|
|
||||||
| `dot install --restore` | Reinstalls every package from the tracked list. |
|
|
||||||
| `dot <git>` | Everything else is passed to `git`. |
|
|
||||||
|
|
||||||
See [CLAUDE.md](../.config/dot/CLAUDE.md) for the `dot` tool's internal
|
|
||||||
architecture, bootstrap logic, subcommand dispatch, and test suite.
|
|
||||||
|
|
||||||
## Keybindings
|
|
||||||
|
|
||||||
See [keybindings.md](keybindings.md) for custom and useful default
|
|
||||||
keybindings across configured tools (currently: tmux).
|
|
||||||
37
.github/keybindings.md
vendored
37
.github/keybindings.md
vendored
@@ -1,37 +0,0 @@
|
|||||||
# Keybindings
|
|
||||||
|
|
||||||
Quick reference for custom and useful default keybindings, so they don't have
|
|
||||||
to be re-discovered or looked up per tool.
|
|
||||||
|
|
||||||
Comma-separated keys are pressed in sequence, not together.
|
|
||||||
|
|
||||||
| Key | Context | Action |
|
|
||||||
| ----------------------------------------------------- | ------- | -------------------------------------------------- |
|
|
||||||
| `Ctrl` + `Space`, `\` | tmux | Split side-by-side, opens in current directory |
|
|
||||||
| `Ctrl` + `Space`, `-` | tmux | Split stacked, opens in current directory |
|
|
||||||
| `Ctrl` + `Space`, `h` / `j` / `k` / `l` | tmux | Move focus left / down / up / right |
|
|
||||||
| `Ctrl` + `Space`, `z` | tmux | Zoom/unzoom pane to fullscreen |
|
|
||||||
| `Ctrl` + `Space`, `o` | tmux | Cycle focus to next pane |
|
|
||||||
| `Ctrl` + `Space`, `x` | tmux | Kill current pane (asks to confirm) |
|
|
||||||
| `Ctrl` + `Space`, `Ctrl` + `Up`/`Down`/`Left`/`Right` | tmux | Resize pane |
|
|
||||||
| `Ctrl` + `Space`, `c` | tmux | New window, opens in current directory |
|
|
||||||
| `Ctrl` + `Space`, `0`-`9` | tmux | Jump to window by number |
|
|
||||||
| `Ctrl` + `Space`, `n` / `p` | tmux | Next / previous window |
|
|
||||||
| `Ctrl` + `Space`, `w` | tmux | Interactive window list |
|
|
||||||
| `Ctrl` + `Space`, `,` | tmux | Rename current window |
|
|
||||||
| `Ctrl` + `Space`, `&` | tmux | Kill current window (asks to confirm) |
|
|
||||||
| `Ctrl` + `Space`, `[` | tmux | Enter copy mode |
|
|
||||||
| `Ctrl` + `Space`, `]` | tmux | Paste most recent copy |
|
|
||||||
| `h` / `j` / `k` / `l` | tmux | Move cursor |
|
|
||||||
| `v` | tmux | Begin selection |
|
|
||||||
| `y` | tmux | Copy selection to system clipboard, exit copy mode |
|
|
||||||
| `/` / `?` | tmux | Search forward / backward |
|
|
||||||
| `q` | tmux | Exit copy mode |
|
|
||||||
| `Ctrl` + `Space`, `d` | tmux | Detach from session |
|
|
||||||
| `Ctrl` + `Space`, `$` | tmux | Rename session |
|
|
||||||
| `Ctrl` + `Space`, `s` | tmux | Interactive session list |
|
|
||||||
| `Ctrl` + `Space`, `(` / `)` | tmux | Switch to previous / next session |
|
|
||||||
| `Ctrl` + `Space`, `r` | tmux | Reload `tmux.conf` |
|
|
||||||
| `Ctrl` + `h` / `j` / `k` / `l` | neovim | Move focus between splits left / down / up / right |
|
|
||||||
| `Esc` | neovim | Clear search highlight |
|
|
||||||
| `Space`, `e` | neovim | Toggle file explorer (netrw) |
|
|
||||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,6 +1,8 @@
|
|||||||
.dotfiles
|
/reference/
|
||||||
.DS_Store
|
/.direnv/
|
||||||
*.swp
|
|
||||||
*.swo
|
# BEGIN mkSkillsShellHook
|
||||||
*~
|
# Generated by mkSkillsShellHook. Nix-delivered skill symlinks, kept out of git.
|
||||||
Thumbs.db
|
.claude/skills
|
||||||
|
.agents/skills/gitea-axi
|
||||||
|
# END mkSkillsShellHook
|
||||||
|
|||||||
34
.sops.yaml
Normal file
34
.sops.yaml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Recipients for the encrypted files under secrets/.
|
||||||
|
keys:
|
||||||
|
# A recipient of every file.
|
||||||
|
# One readable only by machines becomes unrecoverable once they are wiped.
|
||||||
|
# Adding a recipient requires decrypting first.
|
||||||
|
# No private half here, only in the operator's password manager.
|
||||||
|
- &admin age1m0pk94ysjlw3lmf6pyuv5l5pepvdjss8w0vxjv90dq6ndp02tdgsdwdvue
|
||||||
|
# Generated on the machine it names.
|
||||||
|
- &neogaia age14a04vphzjq74epfrz9a09wjw8lzchtru84awzuq2n45d8f42ychqjs89qe
|
||||||
|
- &pikachu age1wf5s0n0tgt6ld2ysgu9dc67mj8ylwecgl4utzg7hqwy3kut9zyms7aglmh
|
||||||
|
|
||||||
|
creation_rules:
|
||||||
|
# Material belonging to one machine.
|
||||||
|
# No machine other than the one named is a recipient, so a host that is
|
||||||
|
# compromised cannot decrypt another's material.
|
||||||
|
- path_regex: secrets/neogaia\.yaml$
|
||||||
|
key_groups:
|
||||||
|
- age:
|
||||||
|
- *admin
|
||||||
|
- *neogaia
|
||||||
|
|
||||||
|
- path_regex: secrets/pikachu\.yaml$
|
||||||
|
key_groups:
|
||||||
|
- age:
|
||||||
|
- *admin
|
||||||
|
- *pikachu
|
||||||
|
|
||||||
|
# Material common to every machine, so it is stored once rather than per host.
|
||||||
|
- path_regex: secrets/shared\.yaml$
|
||||||
|
key_groups:
|
||||||
|
- age:
|
||||||
|
- *admin
|
||||||
|
- *neogaia
|
||||||
|
- *pikachu
|
||||||
94
AGENTS.md
Normal file
94
AGENTS.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# dotfiles-nixos
|
||||||
|
|
||||||
|
One flake that builds every machine the user owns.
|
||||||
|
The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overlays) lives in `~/Documents/ai-artifacts/projects/dotfiles/003-dotfiles-context.md`.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- Comments posted to Gitea (pull requests, issues, reviews) go out under the operator's account, so sign every one to make clear the author is the agent, not the operator.
|
||||||
|
End the comment with a `— Claude` sign-off.
|
||||||
|
(A dedicated bot account may replace this later.
|
||||||
|
Until then, the sign-off is the only marker.)
|
||||||
|
- Commit messages follow Conventional Commits, specified in `docs/conventional-commits.md`.
|
||||||
|
Scope is the module or host the change belongs to (`fish`, `nvim`, `neogaia`), omitted for repo-wide changes.
|
||||||
|
Keep messages free of Gitea-specific references: this repository is mirrored to GitHub, where issue and pull-request numbers resolve to unrelated things.
|
||||||
|
- When a graphical application is added, give it a `window-rewrite` icon mapping in `modules/desktop/waybar.nix`.
|
||||||
|
Without one its windows fall back to the generic default glyph on the workspace indicator instead of showing a recognisable per-application icon.
|
||||||
|
Match on the window class, which `hyprctl clients -j | jq -r '.[].class' | sort -u` lists for the running session.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Subagent completion delivery is non-blocking through immediate spawn, milestone notifications, retained terminal entries, and `subagent_list` or `subagent_result` retrieval.
|
||||||
|
`subagent_wait` intentionally blocks the parent tool call until its condition or timeout, so do not use it merely to keep background work alive during an interactive workflow.
|
||||||
|
- Nixvim's flake input following the root nixpkgs source does not make its Home Manager module reuse the host's `pkgs` instance.
|
||||||
|
Keep `programs.nixvim.nixpkgs.useGlobalPackages = true` so Nixvim uses the shared package set without warning that its source default was affected.
|
||||||
|
- This host has no `python` or `python3` command on its ordinary `PATH`.
|
||||||
|
For ad hoc Python, use Nix explicitly, such as `nix shell nixpkgs#python3 -c python3 <script>`.
|
||||||
|
- ADR bodies are immutable records of decisions as they were made, while frontmatter is mutable.
|
||||||
|
When a decision changes or its premise proves wrong, preserve the original body, update its status, and add a new ADR that supersedes it.
|
||||||
|
Filename migrations preserve references in immutable bodies through frontmatter aliases rather than rewriting those bodies.
|
||||||
|
- This repo pins no Nix formatter, and its committed `.nix` files are not clean under current `nixfmt-rfc-style`.
|
||||||
|
Running `nixfmt` across a file reflows untouched code (for example `lib.nix`'s `deriveMac` list and multi-line assertion messages) and injects churn unrelated to the change.
|
||||||
|
Format only the lines being written or changed, matching the surrounding style by hand.
|
||||||
|
- This repo is developed on `neogaia`, which now runs the NixOS it builds.
|
||||||
|
Flakes and the chaotic substituter come from this flake's own `nix.settings`, so no `NIX_CONFIG` export or per-command `--extra-experimental-features` is needed, and building a toplevel with `boot.kernelPackages = linuxPackages_cachyos` fetches the kernel from `nyx-cache` rather than compiling it.
|
||||||
|
Both were true only while the machine still ran CachyOS against a distro Nix daemon.
|
||||||
|
- Git identity is declared in the flake by `modules/git.nix`, which writes `alexion <contact@alexion.dev>` — the identity all history uses — on any host enabling `modules.git`.
|
||||||
|
Every new host has to enable it, so that a host reads as a full checklist of what it carries.
|
||||||
|
It is deployed on `neogaia` and verified: a commit in a repository outside this checkout is authored `alexion <contact@alexion.dev>` with no override.
|
||||||
|
Verify it that way rather than from this checkout, whose `.git/config` carries the same identity and would mask a broken module.
|
||||||
|
`~/.gitconfig` (a second global file that outranks the flake-managed `~/.config/git/config`) currently holds only a `tea` credential helper and no `user.*`, so it does not shadow the identity, but it is undeclared and will not survive a reimage.
|
||||||
|
- The primary build/verify seam for any Host is `nix flake check`, which builds `checks.x86_64-linux.<host>` (the system toplevel).
|
||||||
|
Cheap targeted checks use `nix eval .#nixosConfigurations.<host>.config...`.
|
||||||
|
- chaotic-nyx must **not** follow our `nixpkgs`, and its packages are built against chaotic's own pinned nixpkgs (its overlay defaults to `onTopOf = "flake-nixpkgs"`, the cache-friendly path).
|
||||||
|
That is what lets the `nyx-cache.chaotic.cx` binary cache hit instead of compiling the CachyOS kernel from source.
|
||||||
|
The tradeoff is that chaotic packages do not see our `unstable`/`stable` overlays.
|
||||||
|
- The remote is self-hosted Gitea (`git.alexion.dev`), and the forge CLI is `gitea-axi` rather than `tea`.
|
||||||
|
`gitea-axi` resolves the repository from the `origin` remote and discovers credentials from a `tea` login whose host matches the remote, so both are implicit inside a checkout.
|
||||||
|
It is installed on `neogaia` by `modules.agents.tools.gitea-axi`, and verified: `gitea-axi` run from this checkout renders the `alexion/dotfiles` dashboard authenticated, so the claude-code `SessionStart` hook that runs it now resolves to a real binary rather than a missing one.
|
||||||
|
The package wraps the binary so `git` and `tea` are reachable without being on `PATH`, while still preferring the operator's own where present.
|
||||||
|
Credentials: `~/.config/tea/config.yml` holds a token-bearing login named `alexion`, which `gitea-axi` uses and which also opens pull requests directly with `nix run nixpkgs#tea -- pr create --login alexion --repo alexion/dotfiles --base main --head <branch> ...`.
|
||||||
|
The `--repo` flag is required on that path, since `tea` resolves `origin` only for a login whose SSH host matches.
|
||||||
|
The same token reads PR discussion, which `tea` itself does poorly: `tea pr <n> --comments` prints only the body, and `-f comments` returns no comments field at all.
|
||||||
|
Use the API instead, taking the token from `.logins[] | select(.name=="alexion") | .token`.
|
||||||
|
Review comments are **not** at `/issues/<n>/comments` — that endpoint holds only top-level discussion and is usually empty.
|
||||||
|
Inline comments need two calls: `/pulls/<n>/reviews` for the review ids, then `/pulls/<n>/reviews/<id>/comments` for the bodies, whose `path` and `diff_hunk` fields say what each one is attached to.
|
||||||
|
A review row with an empty `body` is the normal shape when the operator left only inline comments.
|
||||||
|
- `~/.claude/skills` and `~/.pi/agent/skills` are home-manager-generated (`recursive = true`), so editing a skill in place fails and a new file created there silently escapes the repo.
|
||||||
|
Shared global skills come from the `skills` flake through `modules/agents/skills.nix`, applied by a rebuild.
|
||||||
|
Claude-specific legacy skills, when kept, live under `modules/agents/claude-code/skills/<name>/`.
|
||||||
|
- Pi skill discovery honors `.gitignore`, `.ignore`, and `.fdignore` inside scanned skill directories.
|
||||||
|
A generated `.agents/skills/.gitignore` entry that ignores a symlinked skill also prevents Pi from loading that skill, even when `.agents/skills/<name>/SKILL.md` exists and the symlink target is valid.
|
||||||
|
- nixpkgs `vimPlugins.nord-nvim` is `shaunsingh/nord.nvim` (no `require("nord").setup()`).
|
||||||
|
The config wants `gbprod/nord.nvim`, which is packaged as `vimPlugins.gbprod-nord`.
|
||||||
|
- `nixos-generate-config --show-hardware-config` needs root on this machine even just to print: unprivileged it dies at `Failed to retrieve subvolume info for /`, because the root filesystem is btrfs.
|
||||||
|
- This repo's claude-code module sets sudo's credential cache to per-user (`timestamp_type=global`, 60-minute window), so an authentication made in one real terminal counts for the agent's commands.
|
||||||
|
A `PreToolUse` hook refuses privileged commands while the cache is cold, so a cold cache announces itself instead of stalling.
|
||||||
|
A privileged-command failure *without* that message is the sandbox, not the cache.
|
||||||
|
- Host GPUs: `neogaia` is Intel, `zeus` (the desktop) is **AMD**, and `raichu` (a headless server) is the only Nvidia machine.
|
||||||
|
The corrected fact also lives in artifact `006-dotfiles-hyprland-compositor-adr.md`.
|
||||||
|
- This repo's `programs.firefox` `search` (with `force = true`) writes `search.json.mozlz4`.
|
||||||
|
Omission alone does not prune a built-in engine, since Firefox reconciles its app-provided engines back in, so remove one by listing it with `<engine>.metaData.hidden = true`.
|
||||||
|
Engines are referenced by their current id, so the default is `default = "ddg"`, not `"DuckDuckGo"`.
|
||||||
|
Decode the built file with `mozlz4a -d <search.json.mozlz4>` to check the result.
|
||||||
|
- Any non-empty Home Manager Firefox `profiles.<name>.extensions.settings.<id>.settings` causes Home Manager to set `extensions.webextensions.ExtensionStorageIDB.enabled = false` globally for that profile.
|
||||||
|
This repo's Stylix Firefox `colorTheme` settings trigger it, so every extension in the profile uses the legacy extension-storage backend regardless of how it is installed.
|
||||||
|
- `home.sessionVariables` do **not** reach the Hyprland session, since UWSM does not source `hm-session-vars.sh`.
|
||||||
|
The cursor is therefore set through Hyprland's own `env = KEY,VALUE` in `modules/desktop/hyprland/hyprland.nix`, sourced from `config.stylix.cursor`.
|
||||||
|
Bibata ships XCursor format only (no `hyprcursor/` dir), rendered through Hyprland's XCursor fallback, so `XCURSOR_*` and `HYPRCURSOR_*` naming the same theme are both safe.
|
||||||
|
- `neogaia`, the repo's only host, is a wifi laptop with a btrfs root and no ZFS pools, so it cannot honestly carry `modules.network`, `modules.zfs`, or a networked/pool-mounted guest.
|
||||||
|
Enabling networkd takes over its DNS, its CachyOS `zfs-kernel` build is marked broken, and it has no bridge or pool to attach to.
|
||||||
|
Verify these against it ad hoc through `nixosConfigurations.neogaia.extendModules` (forcing a ZFS-capable `boot.kernelPackages` for the zfs case) plus `nix eval` of the derived values, never by committing the enablement.
|
||||||
|
A committed guest therefore leaves `vlan`, `mounts`, and `secrets` unset, and the standing enablement waits for the first wired server host with real storage.
|
||||||
|
- Herdr key names for shifted punctuation are not interchangeable with the physical base key plus `shift`.
|
||||||
|
The tab rename binding must use the produced literal, such as `prefix+<`, rather than `prefix+shift+comma`.
|
||||||
|
- Flake-managed Pi extension, prompt, and skill directories may still be written directly for throwaway development or local experiments.
|
||||||
|
The risk is that a later Home Manager activation can overwrite or hide those unmanaged files, so finished work must be promoted into the dotfiles module before it counts as deployed.
|
||||||
|
- Pi's tool discovery checks `~/.pi/agent/bin` before `PATH`, and downloaded generic Linux binaries there can be unusable on NixOS with the stub-ld error.
|
||||||
|
This flake patches Pi to validate local tool binaries before selecting them, so it falls back to usable `fd`/`rg` from `PATH` instead.
|
||||||
|
Stale unpatched launchers are the remaining failure mode for broken `@` autocomplete.
|
||||||
|
- Nix flake evaluation ignores untracked files in this checkout.
|
||||||
|
Keep a new auto-loaded module staged or committed until it is removed, otherwise `nix flake check` and `nixos-rebuild --flake` evaluate without it and report its options as missing.
|
||||||
|
- The current Steam desktop client is an XWayland application.
|
||||||
|
Its CEF windows do not support Ozone and Steam composites them into an SDL surface with X11 extensions, so SDL Wayland selectors do not make the visible client native Wayland.
|
||||||
|
Keep fractional scaling sharp with Hyprland's `xwayland.force_zero_scaling` and Steam's own `STEAM_FORCE_DESKTOPUI_SCALING` instead.
|
||||||
5
CLAUDE.md
Normal file
5
CLAUDE.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Claude Code compatibility
|
||||||
|
|
||||||
|
You MUST read and follow [`AGENTS.md`](AGENTS.md) before doing any work in this repository.
|
||||||
|
`AGENTS.md` is the canonical project instruction file.
|
||||||
|
This file exists only so Claude Code discovers that canonical instruction file.
|
||||||
91
base.nix
Normal file
91
base.nix
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
inputs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
# The shared foundation both the host base and the guest-base build on: the
|
||||||
|
# primary user, home-manager, and the fresher/pinned package overlays.
|
||||||
|
let
|
||||||
|
inherit (lib) mkOption types;
|
||||||
|
user = config.user;
|
||||||
|
|
||||||
|
# Args to instantiate an extra nixpkgs source on the base platform.
|
||||||
|
pinArgs = prev: {
|
||||||
|
inherit (prev.stdenv.hostPlatform) system;
|
||||||
|
config.allowUnfree = true;
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
imports = [ inputs.home-manager.nixosModules.home-manager ];
|
||||||
|
|
||||||
|
options.user = {
|
||||||
|
name = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "alexion";
|
||||||
|
description = ''
|
||||||
|
The primary interactive user this system is built for. Drives both the
|
||||||
|
system account and the home-manager user in lockstep.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
description = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "Alexion";
|
||||||
|
description = "Human-readable description (GECOS field) for the primary user.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = {
|
||||||
|
# Reach fresher packages with `unstable.<name>` or pin with `stable.<name>`.
|
||||||
|
nixpkgs.overlays = [
|
||||||
|
(_final: prev: {
|
||||||
|
unstable = import inputs.nixpkgs-unstable (pinArgs prev);
|
||||||
|
stable = import inputs.nixpkgs-stable (pinArgs prev);
|
||||||
|
})
|
||||||
|
];
|
||||||
|
nixpkgs.config.allowUnfree = true;
|
||||||
|
|
||||||
|
# Flakes, so `nixos-rebuild switch` works from the console and a direnv
|
||||||
|
# `use flake` resolves inside a guest.
|
||||||
|
nix.settings.experimental-features = [
|
||||||
|
"nix-command"
|
||||||
|
"flakes"
|
||||||
|
];
|
||||||
|
|
||||||
|
# Primary user.
|
||||||
|
# The wheel group is the way in, since root is locked.
|
||||||
|
# No password is set here, since that is host-only.
|
||||||
|
# A guest therefore has none and is reached by SSH key or `machinectl`.
|
||||||
|
users.users.${user.name} = {
|
||||||
|
isNormalUser = true;
|
||||||
|
description = user.description;
|
||||||
|
extraGroups = [
|
||||||
|
"wheel"
|
||||||
|
"storage"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
# The shared write group.
|
||||||
|
# Its gid is fixed, so a host and every guest carry the same number.
|
||||||
|
# An identity-mapped container write then lands on the pool as this group, sparing every service the permission juggling.
|
||||||
|
# 10000 clears the system-group ids assigned automatically and leaves headroom above the primary user, so nothing else claims it.
|
||||||
|
users.groups.storage.gid = 10000;
|
||||||
|
|
||||||
|
# home-manager as a NixOS module: one build produces the system and user
|
||||||
|
# environment together, sharing the system's pkgs and installing user
|
||||||
|
# packages into the system profile.
|
||||||
|
home-manager = {
|
||||||
|
useGlobalPkgs = true;
|
||||||
|
useUserPackages = true;
|
||||||
|
extraSpecialArgs = {
|
||||||
|
inherit inputs;
|
||||||
|
my = inputs.self.lib;
|
||||||
|
};
|
||||||
|
users.${user.name} = {
|
||||||
|
home.username = user.name;
|
||||||
|
home.homeDirectory = "/home/${user.name}";
|
||||||
|
home.stateVersion = "26.05";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
57
docs/conventional-commits.md
Normal file
57
docs/conventional-commits.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Conventional Commits
|
||||||
|
|
||||||
|
> Sourced from [conventionalcommits.org/en/v1.0.0](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||||
|
> by the Conventional Commits authors, licensed under
|
||||||
|
> [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The Conventional Commits specification is a lightweight convention on top of commit messages.
|
||||||
|
It provides an easy set of rules for creating an explicit commit history; which makes it easier to write automated tools on top of.
|
||||||
|
This convention dovetails with [SemVer](http://semver.org), by describing the features, fixes, and breaking changes made in commit messages.
|
||||||
|
|
||||||
|
The commit message should be structured as follows:
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>[optional scope]: <description>
|
||||||
|
|
||||||
|
[optional body]
|
||||||
|
|
||||||
|
[optional footer(s)]
|
||||||
|
```
|
||||||
|
|
||||||
|
The commit contains the following structural elements, to communicate intent to the consumers of your library:
|
||||||
|
|
||||||
|
1. **fix:** a commit of the _type_ `fix` patches a bug in your codebase (this correlates with `PATCH` in Semantic Versioning).
|
||||||
|
2. **feat:** a commit of the _type_ `feat` introduces a new feature to the codebase (this correlates with `MINOR` in Semantic Versioning).
|
||||||
|
3. **BREAKING CHANGE:** a commit that has a footer with a token `BREAKING CHANGE:`, or appends a `!` after the type/scope, introduces a breaking API change (correlating with `MAJOR` in Semantic Versioning). A BREAKING CHANGE can be part of commits of any _type_.
|
||||||
|
4. _types_ other than `fix:` and `feat:` are allowed, for example `build:`, `chore:`, `ci:`, `docs:`, `style:`, `refactor:`, `perf:`, `test:`, and others.
|
||||||
|
5. _footers_ other than `BREAKING CHANGE: <description>` may be provided and follow a convention similar to [git trailer format](https://git-scm.com/docs/git-interpret-trailers).
|
||||||
|
|
||||||
|
Additional types are not mandated by the Conventional Commits specification, and have no implicit effect in Semantic Versioning (unless they include a BREAKING CHANGE).
|
||||||
|
A scope may be provided to a commit's type, to provide additional contextual information and is contained within parenthesis, e.g., `feat(parser): add ability to parse arrays`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Specification
|
||||||
|
|
||||||
|
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119](https://www.ietf.org/rfc/rfc2119.txt).
|
||||||
|
|
||||||
|
1. Commits MUST be prefixed with a type, which consists of a noun, `feat`, `fix`, etc., followed by the OPTIONAL scope, OPTIONAL `!`, and REQUIRED terminal colon and space.
|
||||||
|
2. The type `feat` MUST be used when a commit adds a new feature to your application or library.
|
||||||
|
3. The type `fix` MUST be used when a commit represents a bug fix for your application.
|
||||||
|
4. A scope MAY be provided after a type. A scope MUST consist of a noun describing a section of the codebase surrounded by parenthesis, e.g., `fix(parser):`.
|
||||||
|
5. A description MUST immediately follow the colon and space after the type/scope prefix. The description is a short summary of the code changes, e.g., _fix: array parsing issue when multiple spaces were contained in string_.
|
||||||
|
6. A longer commit body MAY be provided after the short description, providing additional contextual information about the code changes. The body MUST begin one blank line after the description.
|
||||||
|
7. A commit body is free-form and MAY consist of any number of newline separated paragraphs.
|
||||||
|
8. One or more footers MAY be provided one blank line after the body. Each footer MUST consist of a word token, followed by either a `:<space>` or `<space>#` separator, followed by a string value (this is inspired by the [git trailer convention](https://git-scm.com/docs/git-interpret-trailers)).
|
||||||
|
9. A footer's token MUST use `-` in place of whitespace characters, e.g., `Acked-by` (this helps differentiate the footer section from a multi-paragraph body). An exception is made for `BREAKING CHANGE`, which MAY also be used as a token.
|
||||||
|
10. A footer's value MAY contain spaces and newlines, and parsing MUST terminate when the next valid footer token/separator pair is observed.
|
||||||
|
11. Breaking changes MUST be indicated in the type/scope prefix of a commit, or as an entry in the footer section.
|
||||||
|
12. If included as a footer, a breaking change MUST consist of the uppercase text `BREAKING CHANGE`, followed by a colon, space, and description, e.g., _BREAKING CHANGE: environment variables now take precedence over config files_.
|
||||||
|
13. If included in the type/scope prefix, breaking changes MUST be indicated by a `!` immediately before the `:`. If `!` is used, `BREAKING CHANGE:` MAY be omitted from the footer section, and the commit description SHALL be used to describe the breaking change.
|
||||||
|
14. Types other than `feat` and `fix` MAY be used in your commit messages, e.g., _docs: correct spelling of CHANGELOG_.
|
||||||
|
15. The units of information that make up Conventional Commits MUST NOT be treated as case sensitive by implementors, with the exception of BREAKING CHANGE which MUST be uppercase.
|
||||||
|
16. BREAKING-CHANGE MUST be synonymous with BREAKING CHANGE, when used as a token in a footer.
|
||||||
386
docs/install.md
Normal file
386
docs/install.md
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
# Installing and provisioning a host
|
||||||
|
|
||||||
|
This document covers the procedures that put a machine into the fleet and keep its secrets readable.
|
||||||
|
|
||||||
|
- [Installing a host from the live ISO](#installing-a-host-from-the-live-iso), the destructive one-shot that turns a host in this flake into a running, encrypted machine.
|
||||||
|
- [Provisioning an already-running host](#provisioning-an-already-running-host), done live on the machine with no reimage.
|
||||||
|
- [Editing secrets](#editing-secrets), the day-to-day workflow.
|
||||||
|
- [Recovering a wrongly-provisioned machine](#recovering-a-wrongly-provisioned-machine) from the live ISO.
|
||||||
|
|
||||||
|
The install is destructive: it formats the target disk in full.
|
||||||
|
Read it end to end before starting, because on a single-machine fleet the reimage is irreversible.
|
||||||
|
|
||||||
|
## What arrives by hand
|
||||||
|
|
||||||
|
Exactly one secret is entered by hand: the **LUKS passphrase** that encrypts the disk, typed when the disk is formatted and again at every boot.
|
||||||
|
|
||||||
|
Everything else arrives declared.
|
||||||
|
The login password is a `sops`-encrypted secret consumed through `hashedPasswordFile`, and the SSH host keys are restored from secrets rather than generated.
|
||||||
|
No password is set interactively at any point, and `users.mutableUsers = false` means one set by hand would be ignored anyway.
|
||||||
|
|
||||||
|
## Identity before first boot
|
||||||
|
|
||||||
|
A machine reads its secrets with an **age identity** at `/var/lib/sops-nix/key.txt` on its encrypted root.
|
||||||
|
Its public half must be registered as a recipient of every secrets file the machine needs, and the re-keyed files must be in the flake's git tree when the system is built, because the ciphertext is baked into the store.
|
||||||
|
|
||||||
|
**A host's identity is therefore generated and registered before its first boot, not after it.**
|
||||||
|
The login password arrives only from a decrypted secret and there is no fallback credential — no interactive password, no unlocked root account, no authorized SSH key.
|
||||||
|
A first boot without a registered identity cannot decrypt the password hash, so the account it would log in as has no usable password and the machine has no way in short of the [recovery procedure](#recovering-a-wrongly-provisioned-machine).
|
||||||
|
|
||||||
|
Identities come in two tiers.
|
||||||
|
The **admin identity** lives in Proton Pass, is a recipient of every file, and is the credential that authorizes registering a new host.
|
||||||
|
Each **host identity** is generated on its own machine, never transmitted, and reads only that machine's file plus the shared one.
|
||||||
|
A host identity is deliberately not derived from the machine's SSH host key, which is what frees those host keys to be secrets in their own right.
|
||||||
|
|
||||||
|
## Tooling
|
||||||
|
|
||||||
|
Neither `sops` nor `age` is installed by this flake.
|
||||||
|
Run them from nixpkgs as needed:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ nix run nixpkgs#sops -- <args>
|
||||||
|
$ nix shell nixpkgs#age -c age-keygen <args>
|
||||||
|
```
|
||||||
|
|
||||||
|
On the live ISO these need `--extra-experimental-features 'nix-command flakes'`, since the ISO's daemon has neither enabled.
|
||||||
|
|
||||||
|
## Installing a host from the live ISO
|
||||||
|
|
||||||
|
### 0. Push the repo to Gitea
|
||||||
|
|
||||||
|
From your working checkout, make sure `main` is committed and pushed to the Gitea remote:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
The clone in step 2 sees only what has been pushed, so anything left behind in your working checkout will not reach the machine.
|
||||||
|
Changes made inside that clone afterwards are a separate matter — step 4 makes one there deliberately.
|
||||||
|
|
||||||
|
### 1. Boot the live ISO and join wifi
|
||||||
|
|
||||||
|
Boot from a NixOS live ISO (the minimal installer is enough).
|
||||||
|
The installer logs in as the `nixos` user, who has passwordless `sudo`.
|
||||||
|
|
||||||
|
On the minimal ISO, bring up wifi with `wpa_supplicant`:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ sudo systemctl start wpa_supplicant
|
||||||
|
$ wpa_cli
|
||||||
|
> add_network
|
||||||
|
0
|
||||||
|
> set_network 0 ssid "YOUR_SSID"
|
||||||
|
> set_network 0 psk "YOUR_WIFI_PASSWORD"
|
||||||
|
> enable_network 0
|
||||||
|
> quit
|
||||||
|
```
|
||||||
|
|
||||||
|
On the graphical ISO, which ships NetworkManager, use `nmcli` instead:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ nmcli device wifi connect "YOUR_SSID" password "YOUR_WIFI_PASSWORD"
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm you have connectivity (`ping -c1 github.com`) before continuing.
|
||||||
|
|
||||||
|
### 2. Clone the repo locally
|
||||||
|
|
||||||
|
Clone this repo onto the live ISO and work from that local checkout:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ git clone ssh://gitea@git.alexion.dev:2022/alexion/dotfiles.git
|
||||||
|
$ cd dotfiles
|
||||||
|
```
|
||||||
|
|
||||||
|
Cloning over SSH needs your Gitea SSH key present in the live session, since the ISO starts with none.
|
||||||
|
If getting the key onto the ISO is inconvenient, clone over HTTPS instead and tell git to skip the self-signed certificate:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ git -c http.sslVerify=false clone https://git.alexion.dev/alexion/dotfiles.git
|
||||||
|
$ cd dotfiles
|
||||||
|
```
|
||||||
|
|
||||||
|
Do **not** point `disko-install` straight at the Gitea flake URL.
|
||||||
|
Gitea serves HTTPS with a self-signed certificate and expects authentication, and Nix's flake fetcher has no easy way to skip certificate verification or supply those credentials mid-install.
|
||||||
|
A plain `git clone` sidesteps that entirely — over SSH there is no TLS, and over HTTPS git takes the `sslVerify=false` above that the flake fetcher won't — and then `disko-install` consumes the flake from a local path, where no fetch of our repo happens during the build.
|
||||||
|
(Every other flake input is public and still fetched from GitHub over ordinary, valid TLS.
|
||||||
|
Only our own repo is the problem the local clone solves.)
|
||||||
|
|
||||||
|
### 3. Generate the host identity
|
||||||
|
|
||||||
|
Generate the identity in the live session and keep it there until step 6 writes it onto the installed root:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ nix shell nixpkgs#age -c age-keygen -o /tmp/key.txt
|
||||||
|
Public key: age1...
|
||||||
|
```
|
||||||
|
|
||||||
|
`age-keygen` prints the public recipient on generation.
|
||||||
|
Recover it later from the identity itself if the line scrolls away:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ nix shell nixpkgs#age -c age-keygen -y /tmp/key.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
The private half never leaves this session except onto the target disk.
|
||||||
|
Do not copy it into the repo, and do not carry it to another machine.
|
||||||
|
|
||||||
|
### 4. Register the recipient and re-key
|
||||||
|
|
||||||
|
Add the public recipient to `.sops.yaml` as a named anchor, then list it under every file the host must read:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
keys:
|
||||||
|
- &admin age1m0pk94ysjlw3lmf6pyuv5l5pepvdjss8w0vxjv90dq6ndp02tdgsdwdvue
|
||||||
|
- &neogaia age14a04vphzjq74epfrz9a09wjw8lzchtru84awzuq2n45d8f42ychqjs89qe
|
||||||
|
- &newhost age1...
|
||||||
|
|
||||||
|
creation_rules:
|
||||||
|
- path_regex: secrets/shared\.yaml$
|
||||||
|
key_groups:
|
||||||
|
- age:
|
||||||
|
- *admin
|
||||||
|
- *neogaia
|
||||||
|
- *newhost
|
||||||
|
```
|
||||||
|
|
||||||
|
If the host gets a secrets file of its own, give it a rule too.
|
||||||
|
`sops` matches a file against these rules to decide who to encrypt it to, and refuses a file no rule matches with `no matching creation rules found`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- path_regex: secrets/newhost\.yaml$
|
||||||
|
key_groups:
|
||||||
|
- age:
|
||||||
|
- *admin
|
||||||
|
- *newhost
|
||||||
|
```
|
||||||
|
|
||||||
|
Then re-key each file you changed, which rewrites its data key for the new recipient list without touching any value:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ SOPS_AGE_KEY_FILE=/path/to/admin-identity \
|
||||||
|
nix run nixpkgs#sops -- updatekeys secrets/shared.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Re-keying requires an identity that can already decrypt the file.
|
||||||
|
The live ISO holds no host identity of its own, so paste the admin identity out of Proton Pass into a file in the live session for this step.
|
||||||
|
|
||||||
|
Then populate that file, which needs no existing identity because encrypting only reads recipients:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ nix run nixpkgs#sops -- secrets/newhost.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
A host with `modules.ssh.enable` expects one entry per key type, named `ssh-host-<type>-key`, each holding a private key generated with `ssh-keygen -t <type> -N "" -f /tmp/<type>`.
|
||||||
|
The build fails at evaluation if a declared secret is absent from the file, so a host that enables the daemon without these will not install.
|
||||||
|
Commit the matching public halves beside the host's configuration in plaintext, since publishing them is their purpose.
|
||||||
|
|
||||||
|
Stage everything you changed.
|
||||||
|
A flake sees only git-tracked files, so an unstaged `secrets/newhost.yaml` is invisible to evaluation even though it exists on disk:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ git add .sops.yaml secrets/
|
||||||
|
```
|
||||||
|
|
||||||
|
Staging is enough for the build.
|
||||||
|
The commit comes in step 8, and no push is needed here because the install builds from this local clone.
|
||||||
|
|
||||||
|
### 5. Run `disko-install`
|
||||||
|
|
||||||
|
Run the install as root from inside the clone:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ sudo nix --extra-experimental-features 'nix-command flakes' run \
|
||||||
|
github:nix-community/disko/latest#disko-install -- \
|
||||||
|
--flake .#neogaia \
|
||||||
|
--disk main /dev/nvme0n1 \
|
||||||
|
--write-efi-boot-entries \
|
||||||
|
--option extra-substituters https://nyx-cache.chaotic.cx/ \
|
||||||
|
--option extra-trusted-public-keys nyx-cache.chaotic.cx:dJxTrgMC3V3cFfyIiBQDQorG6k1LsqurH/srpMSq7qk=
|
||||||
|
```
|
||||||
|
|
||||||
|
What each part does:
|
||||||
|
|
||||||
|
- `--flake .#neogaia` installs the `neogaia` `Host` from the local clone.
|
||||||
|
- `--disk main /dev/nvme0n1` maps disko's `main` disk to the NVMe device.
|
||||||
|
It matches the device declared in `hosts/neogaia/disk.nix` and is stated explicitly so there is no doubt about the target.
|
||||||
|
- `--write-efi-boot-entries` writes the systemd-boot entry into this machine's NVRAM, because the disk stays in the machine it was installed from.
|
||||||
|
- The two `--option` lines are the important part: they hand the **chaotic binary cache** to the install-time Nix daemon on the live ISO.
|
||||||
|
|
||||||
|
The chaotic substituter must be passed here explicitly.
|
||||||
|
The `nix.settings` in the flake configure the substituters of the *installed* system, not the live ISO's daemon that runs this build.
|
||||||
|
The ISO's daemon has no `substituters` beyond `cache.nixos.org`.
|
||||||
|
Without these two `--option` flags, the build cannot fetch the prebuilt CachyOS kernel and **compiles `linuxPackages_cachyos` (and its toolchain) from source on the USB stick** — a very long detour that the cache avoids.
|
||||||
|
Because the install runs as root, and root is a trusted Nix user, the daemon honours these client-supplied substituter settings.
|
||||||
|
|
||||||
|
Partway through, disko formats the LUKS container and **prompts for a disk-encryption passphrase**.
|
||||||
|
This is the passphrase you will type at every boot to unlock the disk.
|
||||||
|
Choose it deliberately.
|
||||||
|
|
||||||
|
When it finishes it prints `disko-install succeeded`.
|
||||||
|
`disko-install` unmounts the target filesystem on exit, so nothing is mounted at this point — step 6 remounts it.
|
||||||
|
|
||||||
|
### 6. Write the identity onto the installed root
|
||||||
|
|
||||||
|
Remount the just-installed system with disko, which reopens the LUKS container (prompting for the passphrase from step 5) and mounts the subvolumes under `/mnt`:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ sudo nix --extra-experimental-features 'nix-command flakes' run \
|
||||||
|
github:nix-community/disko/latest#disko -- \
|
||||||
|
--mode mount --flake .#neogaia
|
||||||
|
```
|
||||||
|
|
||||||
|
Then place the identity generated in step 3, owned by root and readable by nobody else:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ sudo install -d -m 0755 /mnt/var/lib/sops-nix
|
||||||
|
$ sudo install -m 0400 -o root -g root /tmp/key.txt /mnt/var/lib/sops-nix/key.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
It goes on the root subvolume rather than anywhere mounted later because the password secret is decrypted before user accounts are created, which is earlier than any other mount.
|
||||||
|
|
||||||
|
Confirm the identity matches the recipient you registered before rebooting, since this is the last cheap moment to catch a mismatch:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ sudo nix shell nixpkgs#age -c age-keygen -y /mnt/var/lib/sops-nix/key.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Reboot
|
||||||
|
|
||||||
|
Unmount and reboot into the installed system:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ sudo umount -R /mnt
|
||||||
|
$ sudo reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the USB stick.
|
||||||
|
At boot you are prompted for the LUKS passphrase from step 5.
|
||||||
|
After unlocking, log in at the console as `alexion` with the password from the shared secrets file, and you have a working system with fish, tmux, nvim, and Claude Code.
|
||||||
|
|
||||||
|
If the login is rejected, the identity and the registered recipient disagree — see [recovery](#recovering-a-wrongly-provisioned-machine).
|
||||||
|
|
||||||
|
### 8. Commit the recipient change
|
||||||
|
|
||||||
|
The re-key from step 4 exists only in the live session's clone, which is gone.
|
||||||
|
From a machine that is already a recipient of the affected files, repeat the `.sops.yaml` edit and re-key, then commit and push:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ sudo SOPS_AGE_KEY_FILE=/var/lib/sops-nix/key.txt \
|
||||||
|
nix run nixpkgs#sops -- updatekeys secrets/shared.yaml
|
||||||
|
$ git add .sops.yaml secrets/
|
||||||
|
$ git commit -m "feat(secrets): register newhost as a recipient"
|
||||||
|
$ git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Until this lands, the repo's copy of each file has one recipient fewer than the copy the new machine was built from, and the next rebuild from the repo would lock it out.
|
||||||
|
|
||||||
|
## Provisioning an already-running host
|
||||||
|
|
||||||
|
A machine that is up and running gets its identity live.
|
||||||
|
There is no reimage and no live ISO, because the running generation is the fallback: if activation fails, the rebuild fails and the machine keeps working as it is.
|
||||||
|
|
||||||
|
Generate the identity on the machine itself, straight into place:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ sudo install -d -m 0755 /var/lib/sops-nix
|
||||||
|
$ sudo nix shell nixpkgs#age -c age-keygen -o /var/lib/sops-nix/key.txt
|
||||||
|
$ sudo chmod 0400 /var/lib/sops-nix/key.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Register the printed public recipient in `.sops.yaml` and re-key each file the host must read, exactly as in [step 4](#4-register-the-recipient-and-re-key), using the admin identity.
|
||||||
|
|
||||||
|
Then rebuild:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ sudo nixos-rebuild switch --flake .#neogaia
|
||||||
|
```
|
||||||
|
|
||||||
|
Activation decrypts the secrets with the new identity.
|
||||||
|
Confirm they materialized before trusting the change:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ sudo ls -l /run/secrets/ /run/secrets-for-users/
|
||||||
|
```
|
||||||
|
|
||||||
|
Both directories matter.
|
||||||
|
Ordinary secrets land in `/run/secrets/`, but a secret marked as needed for user creation is decrypted in an earlier stage and lands in `/run/secrets-for-users/` — which is where the login password hash goes, so it is the one to check before rebooting.
|
||||||
|
|
||||||
|
Commit and push the recipient change once the rebuild succeeds.
|
||||||
|
|
||||||
|
## Editing secrets
|
||||||
|
|
||||||
|
Opening a file decrypts it into an editor and re-encrypts on save:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ sudo SOPS_AGE_KEY_FILE=/var/lib/sops-nix/key.txt \
|
||||||
|
nix run nixpkgs#sops -- secrets/shared.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
`sudo` is needed because the identity is mode `0400` and owned by root.
|
||||||
|
|
||||||
|
**What the workstation can do alone** is anything to a file it is already a recipient of.
|
||||||
|
For `neogaia` that is `secrets/shared.yaml` and `secrets/neogaia.yaml`: changing a value, adding a key, and even adding another recipient all work from the host identity, because each only requires decrypting a file the machine can already decrypt.
|
||||||
|
|
||||||
|
**What needs the admin identity** is any file the workstation is not a recipient of — another machine's `secrets/<host>.yaml`.
|
||||||
|
Unlock the admin identity out of Proton Pass for that session and point `SOPS_AGE_KEY_FILE` at it:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ SOPS_AGE_KEY_FILE=/path/to/admin-identity \
|
||||||
|
nix run nixpkgs#sops -- secrets/zeus.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
That friction is the point.
|
||||||
|
A workstation that could decrypt every machine's material would make the admin identity ceremonial, and a compromised laptop would carry the whole fleet with it.
|
||||||
|
The admin identity stays a break-glass credential rather than something sitting unlocked on a machine.
|
||||||
|
|
||||||
|
Two changes need more than a save.
|
||||||
|
Rotating the login password means generating a fresh hash with `mkpasswd`, since `users.mutableUsers = false` makes `passwd` inert, and rebuilding.
|
||||||
|
Re-keying the SSH host keys restarts `sshd`, which is declared and automatic.
|
||||||
|
|
||||||
|
## Recovering a wrongly-provisioned machine
|
||||||
|
|
||||||
|
A machine whose identity and registered recipient disagree boots but cannot be logged into: the password hash never decrypts, and there is no fallback credential.
|
||||||
|
Recovery is from the live ISO.
|
||||||
|
|
||||||
|
Boot the ISO, join wifi, and clone the repo as in steps 1 and 2.
|
||||||
|
Then reopen and mount the encrypted root:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ sudo nix --extra-experimental-features 'nix-command flakes' run \
|
||||||
|
github:nix-community/disko/latest#disko -- \
|
||||||
|
--mode mount --flake .#neogaia
|
||||||
|
```
|
||||||
|
|
||||||
|
Read the identity actually on the disk, and compare it against the recipient the repo registered:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ sudo nix shell nixpkgs#age -c age-keygen -y /mnt/var/lib/sops-nix/key.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**If the repo's recipient is right and the disk's identity is wrong**, replace the identity with the one that matches and reboot.
|
||||||
|
Nothing was built against the wrong key, so no rebuild is needed:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ sudo install -m 0400 -o root -g root /path/to/correct-key.txt /mnt/var/lib/sops-nix/key.txt
|
||||||
|
$ sudo umount -R /mnt && sudo reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
**If the disk's identity is right and the repo's recipient is wrong**, re-key against the identity on the disk, using the admin identity to decrypt, then rebuild the target from the ISO:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ SOPS_AGE_KEY_FILE=/path/to/admin-identity \
|
||||||
|
nix run nixpkgs#sops -- updatekeys secrets/shared.yaml
|
||||||
|
$ git add .sops.yaml secrets/
|
||||||
|
$ sudo NIX_CONFIG="experimental-features = nix-command flakes" \
|
||||||
|
nixos-install --root /mnt --flake .#neogaia --no-root-password \
|
||||||
|
--option extra-substituters https://nyx-cache.chaotic.cx/ \
|
||||||
|
--option extra-trusted-public-keys nyx-cache.chaotic.cx:dJxTrgMC3V3cFfyIiBQDQorG6k1LsqurH/srpMSq7qk=
|
||||||
|
$ sudo umount -R /mnt && sudo reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
The rebuild is required here and not in the first case, because the secrets file is baked into the system closure at build time.
|
||||||
|
`nixos-install` reuses the already-formatted disk rather than touching the partition table, so the LUKS container and its passphrase are untouched, and it is idempotent if it fails partway.
|
||||||
|
The substituter flags matter for the same reason they do during the install: without them the CachyOS kernel is compiled from source on the USB stick.
|
||||||
|
|
||||||
|
If neither identity is recoverable, generate a new one as in [step 3](#3-generate-the-host-identity), register it, re-key, and rebuild — the machine's own secrets are lost, but everything encrypted to the admin identity survives.
|
||||||
744
flake.lock
generated
Normal file
744
flake.lock
generated
Normal file
@@ -0,0 +1,744 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"base16": {
|
||||||
|
"inputs": {
|
||||||
|
"fromYaml": "fromYaml"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1755819240,
|
||||||
|
"narHash": "sha256-qcMhnL7aGAuFuutH4rq9fvAhCpJWVHLcHVZLtPctPlo=",
|
||||||
|
"owner": "SenchoPens",
|
||||||
|
"repo": "base16.nix",
|
||||||
|
"rev": "75ed5e5e3fce37df22e49125181fa37899c3ccd6",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "SenchoPens",
|
||||||
|
"repo": "base16.nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"base16-fish": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1765809053,
|
||||||
|
"narHash": "sha256-XCUQLoLfBJ8saWms2HCIj4NEN+xNsWBlU1NrEPcQG4s=",
|
||||||
|
"owner": "tomyun",
|
||||||
|
"repo": "base16-fish",
|
||||||
|
"rev": "86cbea4dca62e08fb7fd83a70e96472f92574782",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "tomyun",
|
||||||
|
"repo": "base16-fish",
|
||||||
|
"rev": "86cbea4dca62e08fb7fd83a70e96472f92574782",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"base16-helix": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1776754714,
|
||||||
|
"narHash": "sha256-E3OAK27smtATTmX45uoTSRsVD+Y+ZiVVfgM/tjpbtYg=",
|
||||||
|
"owner": "tinted-theming",
|
||||||
|
"repo": "base16-helix",
|
||||||
|
"rev": "4d508123037e7851ad36ebf7d9c48b0e9e1eb581",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "tinted-theming",
|
||||||
|
"repo": "base16-helix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"base16-vim": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1732806396,
|
||||||
|
"narHash": "sha256-e0bpPySdJf0F68Ndanwm+KWHgQiZ0s7liLhvJSWDNsA=",
|
||||||
|
"owner": "tinted-theming",
|
||||||
|
"repo": "base16-vim",
|
||||||
|
"rev": "577fe8125d74ff456cf942c733a85d769afe58b7",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "tinted-theming",
|
||||||
|
"repo": "base16-vim",
|
||||||
|
"rev": "577fe8125d74ff456cf942c733a85d769afe58b7",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"chaotic": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-schemas": "flake-schemas",
|
||||||
|
"home-manager": "home-manager",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1785327209,
|
||||||
|
"narHash": "sha256-heXGjUBU1UsTHFzedDzYct9Cblr6FGzQcYyjCykywh8=",
|
||||||
|
"owner": "chaotic-cx",
|
||||||
|
"repo": "nyx",
|
||||||
|
"rev": "90cfa9864fa08c923dddeca965103ad44663dd64",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "chaotic-cx",
|
||||||
|
"ref": "nyxpkgs-unstable",
|
||||||
|
"repo": "nyx",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"disko": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1781152676,
|
||||||
|
"narHash": "sha256-RxWs5ND31KzTG7wvMM+PMfUjyNpmIEr999lqNARaM5o=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "disko",
|
||||||
|
"rev": "ff8702b4de27f72b4c78573dfb89ec74e36abdf1",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "disko",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"firefox-addons": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"dir": "pkgs/firefox-addons",
|
||||||
|
"lastModified": 1785384175,
|
||||||
|
"narHash": "sha256-sWSJPXpQwKJstL4rdhpAQYCYlHK5wOkEHR8/lNHBVb4=",
|
||||||
|
"owner": "rycee",
|
||||||
|
"repo": "nur-expressions",
|
||||||
|
"rev": "db607f3d0afe811bcb3b16266f28b2fc5af4e74f",
|
||||||
|
"type": "gitlab"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"dir": "pkgs/firefox-addons",
|
||||||
|
"owner": "rycee",
|
||||||
|
"repo": "nur-expressions",
|
||||||
|
"type": "gitlab"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"firefox-gnome-theme": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1782007937,
|
||||||
|
"narHash": "sha256-PbnJr+eB+9Czol3ReI83dUgEhcn0sDK6TSy6ODTQm88=",
|
||||||
|
"owner": "rafaelmardojai",
|
||||||
|
"repo": "firefox-gnome-theme",
|
||||||
|
"rev": "981bd332015397fb1ca033fa982bd61635160c78",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "rafaelmardojai",
|
||||||
|
"repo": "firefox-gnome-theme",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-parts": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs-lib": [
|
||||||
|
"nixvim",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1782949081,
|
||||||
|
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-parts_2": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs-lib": [
|
||||||
|
"stylix",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1782949081,
|
||||||
|
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-schemas": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1780327564,
|
||||||
|
"narHash": "sha256-HiRPtA0spK+Dkgbhz/1zW9glXxNVB+L4Rj2VYmdawb8=",
|
||||||
|
"rev": "6cc9bd98891b1fc6bb2b8cb3277df8bc72799ca6",
|
||||||
|
"revCount": 149,
|
||||||
|
"type": "tarball",
|
||||||
|
"url": "https://api.flakehub.com/f/pinned/DeterminateSystems/flake-schemas/0.5.0/019e83cf-9af3-78b1-ac5b-70e68ad1efe1/source.tar.gz"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"type": "tarball",
|
||||||
|
"url": "https://flakehub.com/f/DeterminateSystems/flake-schemas/%3D0.5.0.tar.gz"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems_2"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fromYaml": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731966426,
|
||||||
|
"narHash": "sha256-lq95WydhbUTWig/JpqiB7oViTcHFP8Lv41IGtayokA8=",
|
||||||
|
"owner": "SenchoPens",
|
||||||
|
"repo": "fromYaml",
|
||||||
|
"rev": "106af9e2f715e2d828df706c386a685698f3223b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "SenchoPens",
|
||||||
|
"repo": "fromYaml",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gitea-axi": {
|
||||||
|
"inputs": {
|
||||||
|
"home-manager": "home-manager_2",
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1785340481,
|
||||||
|
"narHash": "sha256-GSxdQ7w8yYfZnfkXUuZ2fYIKibe9ZU8xDGyqpeTb2tE=",
|
||||||
|
"ref": "refs/heads/main",
|
||||||
|
"rev": "627fc9a32bb80d97923540fc0d3e9661961462ba",
|
||||||
|
"revCount": 83,
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.alexion.dev/alexion/gitea-axi"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.alexion.dev/alexion/gitea-axi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gitea-axi_2": {
|
||||||
|
"inputs": {
|
||||||
|
"home-manager": "home-manager_4",
|
||||||
|
"nixpkgs": [
|
||||||
|
"skills",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1785340481,
|
||||||
|
"narHash": "sha256-GSxdQ7w8yYfZnfkXUuZ2fYIKibe9ZU8xDGyqpeTb2tE=",
|
||||||
|
"ref": "refs/heads/main",
|
||||||
|
"rev": "627fc9a32bb80d97923540fc0d3e9661961462ba",
|
||||||
|
"revCount": 83,
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.alexion.dev/alexion/gitea-axi"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.alexion.dev/alexion/gitea-axi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gnome-shell": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"host": "gitlab.gnome.org",
|
||||||
|
"lastModified": 1776175984,
|
||||||
|
"narHash": "sha256-RJFlFW8GiMei6oqUGrMkGEvVqOH8U7Q8abc1yK4VKD8=",
|
||||||
|
"owner": "GNOME",
|
||||||
|
"repo": "gnome-shell",
|
||||||
|
"rev": "e0fdc4c13250e9a9b8ea9594c83925274f4a5dca",
|
||||||
|
"type": "gitlab"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"host": "gitlab.gnome.org",
|
||||||
|
"owner": "GNOME",
|
||||||
|
"ref": "50.1",
|
||||||
|
"repo": "gnome-shell",
|
||||||
|
"type": "gitlab"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"home-manager": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"chaotic",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1785288465,
|
||||||
|
"narHash": "sha256-nCkxaGRtyNheNTxoc527gjOG0BN2zovsWDQVBeKDMW8=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "home-manager",
|
||||||
|
"rev": "36662afed2fa1c9b69bdd03edb92ad572202ca20",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "home-manager",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"home-manager_2": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"gitea-axi",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1784588016,
|
||||||
|
"narHash": "sha256-ouZe80aWEhMLVMkqICFDN+JUw+0FJtCr/bh+hHtRtMg=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "home-manager",
|
||||||
|
"rev": "deeb6b7eb7e0c44ae1819c051ce175bd92a85100",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "home-manager",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"home-manager_3": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1785306346,
|
||||||
|
"narHash": "sha256-DScBkW0fOgpGPK2trNoX3ryLTlaC14+gglFo/BhGJ4g=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "home-manager",
|
||||||
|
"rev": "e705714e918c3b11affcdd15db2cbe3a070420a0",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "home-manager",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"home-manager_4": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"skills",
|
||||||
|
"gitea-axi",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1784588016,
|
||||||
|
"narHash": "sha256-ouZe80aWEhMLVMkqICFDN+JUw+0FJtCr/bh+hHtRtMg=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "home-manager",
|
||||||
|
"rev": "deeb6b7eb7e0c44ae1819c051ce175bd92a85100",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "home-manager",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"home-manager_5": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"skills",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1784725727,
|
||||||
|
"narHash": "sha256-J5+C9wsO0lhDyUalQzfplDbRjyHDYeEH5+9sdyXtwa8=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "home-manager",
|
||||||
|
"rev": "041a999e8c1c5b731913855909e68d30ca69b8e0",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "home-manager",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixos-hardware": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1785232496,
|
||||||
|
"narHash": "sha256-65EQYIRRpTdpH8lUiB6Mvo5uBkG60aBIzAJuALfx+O0=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixos-hardware",
|
||||||
|
"rev": "2e790b0a6be8ec2b76174ac0931b8ff11919ec98",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixos-hardware",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1785090369,
|
||||||
|
"narHash": "sha256-m0pDuRJG7EDo9ri+4Ksu83VsI+PlxNC9lNBfydejce4=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "624af665418d3c65d544145b4d34ad696439570e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs-stable": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1785133411,
|
||||||
|
"narHash": "sha256-Yjv0WEg39KRYS0rBdTbu6Fc/or/ihAKk13W9sQ6VWd0=",
|
||||||
|
"owner": "nixos",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "2f5a153c270b70cb0f8c11f46d96d6d3bc39f4e3",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nixos",
|
||||||
|
"ref": "nixos-26.05",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs-unstable": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1785301185,
|
||||||
|
"narHash": "sha256-eoS3KQTO0aPWXZvIaRbRAzSSHW3l5wdMFXtT1ISfoKA=",
|
||||||
|
"owner": "nixos",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "9bc02893134c733dd85de46ee4fb2fac696b5529",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nixos",
|
||||||
|
"ref": "nixpkgs-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1785318670,
|
||||||
|
"narHash": "sha256-dN6Ou5x/+23FZLEpYP3IffO+NyJFzUlGumt1uu3MMaY=",
|
||||||
|
"owner": "nixos",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "0954f7ee2f6bb3dc7d4e3d0d8bcb8fd4bde4cfc5",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nixos",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixvim": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-parts": "flake-parts",
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1785364321,
|
||||||
|
"narHash": "sha256-BLuHl+nZKb+FDq3GAM6L+UBEiyVepXANA31fT1F56pw=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "nixvim",
|
||||||
|
"rev": "acd69cc15d57004e8cb4495034320263a3d362ea",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "nixvim",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nur": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-parts": [
|
||||||
|
"stylix",
|
||||||
|
"flake-parts"
|
||||||
|
],
|
||||||
|
"nixpkgs": [
|
||||||
|
"stylix",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1783439237,
|
||||||
|
"narHash": "sha256-WUr8JF2v3n4Y30E5dxv4sAgNJXpVDBoCQNoQ/V4+n4o=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "NUR",
|
||||||
|
"rev": "b70bb66c7bcd162642f3a609bc16843c7059f503",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "NUR",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"chaotic": "chaotic",
|
||||||
|
"disko": "disko",
|
||||||
|
"firefox-addons": "firefox-addons",
|
||||||
|
"gitea-axi": "gitea-axi",
|
||||||
|
"home-manager": "home-manager_3",
|
||||||
|
"nixos-hardware": "nixos-hardware",
|
||||||
|
"nixpkgs": "nixpkgs_2",
|
||||||
|
"nixpkgs-stable": "nixpkgs-stable",
|
||||||
|
"nixpkgs-unstable": "nixpkgs-unstable",
|
||||||
|
"nixvim": "nixvim",
|
||||||
|
"skills": "skills",
|
||||||
|
"sops-nix": "sops-nix",
|
||||||
|
"stylix": "stylix"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skills": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"gitea-axi": "gitea-axi_2",
|
||||||
|
"home-manager": "home-manager_5",
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1785695024,
|
||||||
|
"narHash": "sha256-DLLk6X5zu3cRT50p18uHVdwjGVtiS0t/661M34q02zU=",
|
||||||
|
"ref": "refs/heads/main",
|
||||||
|
"rev": "9b2a6bcd583d7d6bf7e5377c3632f601692df209",
|
||||||
|
"revCount": 54,
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.alexion.dev/alexion/skills"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.alexion.dev/alexion/skills"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sops-nix": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1783174389,
|
||||||
|
"narHash": "sha256-aCWC8ngycU7OdJrU2+Je3qf+1a2ykuBvpPhZT/9tXMc=",
|
||||||
|
"owner": "Mic92",
|
||||||
|
"repo": "sops-nix",
|
||||||
|
"rev": "f1406619a3884cd5c47992a70b8b35c9c0fcb4c9",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "Mic92",
|
||||||
|
"repo": "sops-nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"stylix": {
|
||||||
|
"inputs": {
|
||||||
|
"base16": "base16",
|
||||||
|
"base16-fish": "base16-fish",
|
||||||
|
"base16-helix": "base16-helix",
|
||||||
|
"base16-vim": "base16-vim",
|
||||||
|
"firefox-gnome-theme": "firefox-gnome-theme",
|
||||||
|
"flake-parts": "flake-parts_2",
|
||||||
|
"gnome-shell": "gnome-shell",
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"nur": "nur",
|
||||||
|
"systems": "systems_3",
|
||||||
|
"tinted-kitty": "tinted-kitty",
|
||||||
|
"tinted-schemes": "tinted-schemes",
|
||||||
|
"tinted-tmux": "tinted-tmux",
|
||||||
|
"tinted-zed": "tinted-zed"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1784676123,
|
||||||
|
"narHash": "sha256-ndyanKzw90yX2nUVFmTuYqXidUNymtMfgmIHyNdhht0=",
|
||||||
|
"owner": "danth",
|
||||||
|
"repo": "stylix",
|
||||||
|
"rev": "66714e5ce44269ecc58c20d9196da8dbe1b27a31",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "danth",
|
||||||
|
"repo": "stylix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1774449309,
|
||||||
|
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "c29398b59d2048c4ab79345812849c9bd15e9150",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"ref": "future-26.11",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems_3": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tinted-kitty": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1735730497,
|
||||||
|
"narHash": "sha256-4KtB+FiUzIeK/4aHCKce3V9HwRvYaxX+F1edUrfgzb8=",
|
||||||
|
"owner": "tinted-theming",
|
||||||
|
"repo": "tinted-kitty",
|
||||||
|
"rev": "de6f888497f2c6b2279361bfc790f164bfd0f3fa",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "tinted-theming",
|
||||||
|
"repo": "tinted-kitty",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tinted-schemes": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1781968807,
|
||||||
|
"narHash": "sha256-yYO3Vw2M0y3TAUqt+9+Mj0zwP3XDTF5/PXcPhhFQ1ZM=",
|
||||||
|
"owner": "tinted-theming",
|
||||||
|
"repo": "schemes",
|
||||||
|
"rev": "2ccef2f4b22e3cab5a9292811f7133a07eeba4a7",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "tinted-theming",
|
||||||
|
"repo": "schemes",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tinted-tmux": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1782012462,
|
||||||
|
"narHash": "sha256-2iDiD8DQLwS1lGuD9TS8WlvNyDoTs6krWntJbtB2zGo=",
|
||||||
|
"owner": "tinted-theming",
|
||||||
|
"repo": "tinted-tmux",
|
||||||
|
"rev": "8c4e750f738a742bd73377ee41d3dadedebedef4",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "tinted-theming",
|
||||||
|
"repo": "tinted-tmux",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tinted-zed": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1782009766,
|
||||||
|
"narHash": "sha256-VUhBjpGvWqHI7rWeyMYb/u87YJSXHKfVV6S+IelWeO8=",
|
||||||
|
"owner": "tinted-theming",
|
||||||
|
"repo": "base16-zed",
|
||||||
|
"rev": "5e8350bcd354e3241ab681a265fa6ef060c40be1",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "tinted-theming",
|
||||||
|
"repo": "base16-zed",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
110
flake.nix
Normal file
110
flake.nix
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
{
|
||||||
|
description = "Alexion's NixOS configuration — one flake for every host";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
# Base channel.
|
||||||
|
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||||
|
|
||||||
|
# Fresher packages, reachable per-package as `unstable.<name>`.
|
||||||
|
nixpkgs-unstable.url = "github:nixos/nixpkgs/nixpkgs-unstable";
|
||||||
|
|
||||||
|
# Latest stable release, reachable per-package as `stable.<name>`.
|
||||||
|
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
|
||||||
|
|
||||||
|
home-manager = {
|
||||||
|
url = "github:nix-community/home-manager";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Signed AMO extensions, pinned by version and hash.
|
||||||
|
firefox-addons = {
|
||||||
|
url = "gitlab:rycee/nur-expressions?dir=pkgs/firefox-addons";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Follows our nixpkgs so its plugins build against the same package set.
|
||||||
|
nixvim = {
|
||||||
|
url = "github:nix-community/nixvim";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Declarative disk partitioning.
|
||||||
|
# Each host declares its own layout.
|
||||||
|
disko = {
|
||||||
|
url = "github:nix-community/disko";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Upstream per-machine hardware profiles.
|
||||||
|
# Each host imports its own.
|
||||||
|
nixos-hardware = {
|
||||||
|
url = "github:NixOS/nixos-hardware";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Decrypts committed secrets at activation, from an age identity on the host.
|
||||||
|
sops-nix = {
|
||||||
|
url = "github:Mic92/sops-nix";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Themes the graphical layer from one base16 scheme.
|
||||||
|
# Follows our nixpkgs so it themes the same package set the host builds.
|
||||||
|
stylix = {
|
||||||
|
url = "github:danth/stylix";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Agent-ergonomic CLI for Gitea, with a home-manager module for the agent context.
|
||||||
|
gitea-axi = {
|
||||||
|
url = "git+https://git.alexion.dev/alexion/gitea-axi";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Personal agent skills, packaged as per-skill derivations with a home-manager module.
|
||||||
|
skills = {
|
||||||
|
url = "git+https://git.alexion.dev/alexion/skills";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
|
||||||
|
# CachyOS kernel and binary cache.
|
||||||
|
# Pins its own nixpkgs so its cache stays usable and the kernel is fetched from it.
|
||||||
|
chaotic.url = "github:chaotic-cx/nyx/nyxpkgs-unstable";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{ self, nixpkgs, ... }@inputs:
|
||||||
|
let
|
||||||
|
inherit (nixpkgs) lib;
|
||||||
|
my = import ./lib.nix { inherit lib inputs self; };
|
||||||
|
in
|
||||||
|
{
|
||||||
|
# Helper functions for discovering and building hosts.
|
||||||
|
lib = my;
|
||||||
|
|
||||||
|
# Every host under hosts/ is discovered and built.
|
||||||
|
nixosConfigurations = my.mkHosts (self + "/hosts");
|
||||||
|
|
||||||
|
# A project shell for agent-local resources that should travel with this
|
||||||
|
# checkout rather than the operator's global profile.
|
||||||
|
devShells.x86_64-linux.default =
|
||||||
|
let
|
||||||
|
pkgs = nixpkgs.legacyPackages.x86_64-linux;
|
||||||
|
in
|
||||||
|
pkgs.mkShell {
|
||||||
|
packages = [ inputs.gitea-axi.packages.x86_64-linux.gitea-axi ];
|
||||||
|
shellHook = inputs.skills.lib.mkSkillsShellHook [
|
||||||
|
inputs.gitea-axi.packages.x86_64-linux.gitea-axi-skill
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
# `nix flake check` builds each host's toplevel.
|
||||||
|
checks.x86_64-linux = lib.mapAttrs (
|
||||||
|
name: host:
|
||||||
|
if host.config.warnings == [] then
|
||||||
|
host.config.system.build.toplevel
|
||||||
|
else
|
||||||
|
throw "Host ${name} has evaluation warnings:\n${lib.concatStringsSep "\n" host.config.warnings}"
|
||||||
|
) self.nixosConfigurations;
|
||||||
|
};
|
||||||
|
}
|
||||||
31
guest.nix
Normal file
31
guest.nix
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
my,
|
||||||
|
inputs,
|
||||||
|
lib,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
# The guest-base: the slim foundation every nested guest's interior stands on.
|
||||||
|
# It imports the full modules tree so any module is available to enable inside a
|
||||||
|
# guest, and stands on the same shared base a host does.
|
||||||
|
{
|
||||||
|
imports = my.collectNixFiles (inputs.self + "/modules") ++ [
|
||||||
|
(inputs.self + "/base.nix")
|
||||||
|
|
||||||
|
# The modules tree reaches for these option namespaces, so they must be
|
||||||
|
# declared for the tree to evaluate even where a guest leaves them off.
|
||||||
|
inputs.sops-nix.nixosModules.sops
|
||||||
|
inputs.stylix.nixosModules.stylix
|
||||||
|
];
|
||||||
|
|
||||||
|
# A nested container has no per-host `default.nix` to pin its release.
|
||||||
|
system.stateVersion = "26.05";
|
||||||
|
|
||||||
|
# The baseline toolset and SSH access, so any guest shelled into is a workable
|
||||||
|
# environment without per-guest wiring.
|
||||||
|
modules.toolkit.enable = lib.mkDefault true;
|
||||||
|
modules.ssh.enable = lib.mkDefault true;
|
||||||
|
|
||||||
|
# A guest carries no host identity, so it presents a self-generated host key
|
||||||
|
# rather than restoring one from secrets.
|
||||||
|
modules.ssh.hostKeys.restore = lib.mkDefault false;
|
||||||
|
}
|
||||||
11
guests/nesting-sample.nix
Normal file
11
guests/nesting-sample.nix
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
args@{ my, ... }:
|
||||||
|
# A sample guest whose interior runs an OCI container on Podman.
|
||||||
|
# The image is pulled at runtime, so the guest builds with no build-time fetch.
|
||||||
|
my.guest {
|
||||||
|
name = "nesting-sample";
|
||||||
|
interior = {
|
||||||
|
virtualisation.oci-containers.containers.hello = {
|
||||||
|
image = "docker.io/library/hello-world";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
} args
|
||||||
5
guests/sample.nix
Normal file
5
guests/sample.nix
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
args@{ my, ... }:
|
||||||
|
# The tracer-bullet guest: the thinnest complete path from discovery to a
|
||||||
|
# running nested container. Its interior is just the guest-base — the baseline
|
||||||
|
# toolset and SSH access — so it proves the concept without carrying a service.
|
||||||
|
my.guest { name = "sample"; } args
|
||||||
76
hosts/neogaia/default.nix
Normal file
76
hosts/neogaia/default.nix
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
inputs,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
# neogaia — Dell XPS 13 9380 laptop.
|
||||||
|
# Disk layout is in ./disk.nix.
|
||||||
|
# `fileSystems` are derived from it, none declared here.
|
||||||
|
{
|
||||||
|
imports = [
|
||||||
|
inputs.nixos-hardware.nixosModules.dell-xps-13-9380
|
||||||
|
./hardware-configuration.nix
|
||||||
|
./disk.nix
|
||||||
|
];
|
||||||
|
|
||||||
|
system.stateVersion = "26.05";
|
||||||
|
|
||||||
|
# systemd-boot on the EFI system partition.
|
||||||
|
boot.loader.systemd-boot.enable = true;
|
||||||
|
boot.loader.efi.canTouchEfiVariables = true;
|
||||||
|
|
||||||
|
boot.kernelPackages = pkgs.linuxPackages_cachyos;
|
||||||
|
|
||||||
|
# Redistributable firmware for the QCA6174 wifi (ath10k blobs).
|
||||||
|
# Intel microcode updates follow from this, so none is declared here.
|
||||||
|
hardware.enableRedistributableFirmware = true;
|
||||||
|
|
||||||
|
# RAM-backed swap, no on-disk swap partition.
|
||||||
|
zramSwap.enable = true;
|
||||||
|
|
||||||
|
# So wifi can be joined from the console.
|
||||||
|
networking.networkmanager.enable = true;
|
||||||
|
|
||||||
|
# So setup can be driven over the network.
|
||||||
|
# The matching host public keys sit beside this file in plaintext, since
|
||||||
|
# publishing them is their purpose.
|
||||||
|
modules.ssh.enable = true;
|
||||||
|
modules.ssh.hostKeys.sopsFile = ../../secrets/neogaia.yaml;
|
||||||
|
modules.ssh.userKey.sopsFile = ../../secrets/neogaia.yaml;
|
||||||
|
|
||||||
|
modules.toolkit.enable = true;
|
||||||
|
|
||||||
|
# The walking-skeleton guest, enabled like any module: proves the guest path
|
||||||
|
# end to end through this host's `nix flake check`.
|
||||||
|
# Modest caps keep the skeleton guest from starving the laptop.
|
||||||
|
guests.sample.enable = true;
|
||||||
|
guests.sample.limits = {
|
||||||
|
memory = "1G";
|
||||||
|
cpu = "100%";
|
||||||
|
tasksMax = 512;
|
||||||
|
};
|
||||||
|
|
||||||
|
# The nesting guest, run with `nesting` on: proves an interior OCI container
|
||||||
|
# on Podman builds end to end through this host's `nix flake check`.
|
||||||
|
guests.nesting-sample.enable = true;
|
||||||
|
guests.nesting-sample.nesting = true;
|
||||||
|
guests.nesting-sample.limits = {
|
||||||
|
memory = "1G";
|
||||||
|
cpu = "100%";
|
||||||
|
tasksMax = 512;
|
||||||
|
};
|
||||||
|
|
||||||
|
modules.agents.claude-code.enable = true;
|
||||||
|
modules.agents.herdr.enable = true;
|
||||||
|
modules.agents.tools.gitea-axi.enable = true;
|
||||||
|
modules.agents.pi.enable = true;
|
||||||
|
modules.agents.pi.subagents.maxConcurrent = 8;
|
||||||
|
modules.agents.pi.subagents.recentTerminalTtlMs = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
modules.desktop.enable = true;
|
||||||
|
modules.desktop.obsidian.enable = true;
|
||||||
|
modules.desktop.steam.enable = true;
|
||||||
|
|
||||||
|
time.timeZone = "America/New_York";
|
||||||
|
i18n.defaultLocale = "en_GB.UTF-8";
|
||||||
|
}
|
||||||
63
hosts/neogaia/disk.nix
Normal file
63
hosts/neogaia/disk.nix
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
{ ... }:
|
||||||
|
# neogaia's disk layout for disko: one NVMe disk, GPT, with an EFI system
|
||||||
|
# partition and a LUKS container holding btrfs subvolumes.
|
||||||
|
# No swap partition, since swap is zram.
|
||||||
|
# disko derives `fileSystems` and `boot.initrd.luks.devices` from this.
|
||||||
|
{
|
||||||
|
disko.devices.disk.main = {
|
||||||
|
type = "disk";
|
||||||
|
device = "/dev/nvme0n1";
|
||||||
|
content = {
|
||||||
|
type = "gpt";
|
||||||
|
partitions = {
|
||||||
|
ESP = {
|
||||||
|
# Each generation stores a kernel and initrd here and the CachyOS kernel is large.
|
||||||
|
# An exhausted partition fails bootloader installs.
|
||||||
|
size = "2G";
|
||||||
|
type = "EF00";
|
||||||
|
content = {
|
||||||
|
type = "filesystem";
|
||||||
|
format = "vfat";
|
||||||
|
mountpoint = "/boot";
|
||||||
|
mountOptions = [ "umask=0077" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
luks = {
|
||||||
|
size = "100%";
|
||||||
|
content = {
|
||||||
|
type = "luks";
|
||||||
|
name = "cryptroot";
|
||||||
|
settings.allowDiscards = true;
|
||||||
|
content = {
|
||||||
|
type = "btrfs";
|
||||||
|
extraArgs = [ "-f" ];
|
||||||
|
subvolumes = {
|
||||||
|
"@root" = {
|
||||||
|
mountpoint = "/";
|
||||||
|
mountOptions = [
|
||||||
|
"compress=zstd"
|
||||||
|
"noatime"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
"@home" = {
|
||||||
|
mountpoint = "/home";
|
||||||
|
mountOptions = [
|
||||||
|
"compress=zstd"
|
||||||
|
"noatime"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
"@nix" = {
|
||||||
|
mountpoint = "/nix";
|
||||||
|
mountOptions = [
|
||||||
|
"compress=zstd"
|
||||||
|
"noatime"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
17
hosts/neogaia/hardware-configuration.nix
Normal file
17
hosts/neogaia/hardware-configuration.nix
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{ lib, modulesPath, ... }:
|
||||||
|
# Hardware detected by nixos-generate-config on this machine.
|
||||||
|
# disko derives `fileSystems` and the LUKS device, none declared here.
|
||||||
|
{
|
||||||
|
imports = [ (modulesPath + "/installer/scan/not-detected.nix") ];
|
||||||
|
|
||||||
|
boot.initrd.availableKernelModules = [
|
||||||
|
"xhci_pci"
|
||||||
|
"nvme"
|
||||||
|
"rtsx_pci_sdmmc"
|
||||||
|
];
|
||||||
|
boot.initrd.kernelModules = [ ];
|
||||||
|
boot.kernelModules = [ "kvm-intel" ];
|
||||||
|
boot.extraModulePackages = [ ];
|
||||||
|
|
||||||
|
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
|
||||||
|
}
|
||||||
1
hosts/neogaia/ssh_host_ed25519_key.pub
Normal file
1
hosts/neogaia/ssh_host_ed25519_key.pub
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJS+wp7K123+4BT6G4f954R6WyrbWveY7VlpoBUf6I5p neogaia
|
||||||
1
hosts/neogaia/ssh_host_rsa_key.pub
Normal file
1
hosts/neogaia/ssh_host_rsa_key.pub
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCo2wWUKxyAS4J5TqbWf8glDhJvS5XmdRqFhMeJwG3pOB+4AccZ1T8LU7ZN+RjtRi3j2qXBJvIHuzhtQNtmT59TxocvfobYiqOgJpvVO5K6yD8ZoUJs6ziDkIduI9w9mdRIESoi+dBbVu8n24r61cKDVh+jWX+yjzkOcWcOzqDyQhhkjqblZ1WMAdujEMuEPvif1i2LCxStUaZqRGcx09m/ME2fYcaJrpuxxxvX2+CPJNicoo6Rx9i7ZjAoNuvH+jui4KT62DzlQtQtCl2CFUOM0gCPSa+MbNQ9elfHPvGzEcwOIMo2cuy9KURUkQu+sAgaG8S1PEniDDTecskHtuRdmPZawnQGpIhzo919Q6wUgjT8scK4mmSXRWmGmkMt0GNA2tfj5tDks6r5Q8XsYqtWs4rsOEvfmxVSdM771w+fqDBAil99Jsh0ksPK9+Bwgg8cMDzLLFDn8JA5y2G1HocMMom+u5DYKwPXEKnCILkasB8y24+O3PhSu1EuWw277w6EUEXvU03rCf0Ak/ULjxp9a00EGlloEwSmFI7Aub9XHDr87IdbGInEn+PMqyBYADiN+3h6nE2JO+nMa6i/CHdebmT+T7YJvuTKHD9sjFmQsYaghlq03DZrhHcm4hgUvE1dqGojHrhk/WgA3EWTWtK/+BP0Vy2jXaaz+qAx+EGnhQ== neogaia
|
||||||
54
hosts/pikachu/default.nix
Normal file
54
hosts/pikachu/default.nix
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
{ pkgs, ... }:
|
||||||
|
# pikachu — AZW ME Pro server.
|
||||||
|
# Disk layout is in ./disk.nix.
|
||||||
|
# `fileSystems` for the root disk are derived from it.
|
||||||
|
{
|
||||||
|
imports = [
|
||||||
|
./hardware-configuration.nix
|
||||||
|
./disk.nix
|
||||||
|
];
|
||||||
|
|
||||||
|
system.stateVersion = "26.05";
|
||||||
|
|
||||||
|
boot.loader.systemd-boot.enable = true;
|
||||||
|
boot.loader.efi.canTouchEfiVariables = true;
|
||||||
|
|
||||||
|
hardware.cpu.intel.updateMicrocode = true;
|
||||||
|
hardware.enableRedistributableFirmware = true;
|
||||||
|
|
||||||
|
zramSwap.enable = true;
|
||||||
|
|
||||||
|
systemd.network = {
|
||||||
|
enable = true;
|
||||||
|
networks."10-uplink" = {
|
||||||
|
matchConfig.MACAddress = "78:55:36:07:af:49";
|
||||||
|
networkConfig.DHCP = "yes";
|
||||||
|
linkConfig.RequiredForOnline = "routable";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
networking.useDHCP = false;
|
||||||
|
|
||||||
|
boot.zfs.forceImportRoot = false;
|
||||||
|
|
||||||
|
modules.zfs = {
|
||||||
|
enable = true;
|
||||||
|
hostId = "2346edbd";
|
||||||
|
pools.pikachu = { };
|
||||||
|
};
|
||||||
|
|
||||||
|
modules.ssh.enable = true;
|
||||||
|
modules.ssh.hostKeys.sopsFile = ../../secrets/pikachu.yaml;
|
||||||
|
modules.ssh.userKey.sopsFile = ../../secrets/pikachu.yaml;
|
||||||
|
|
||||||
|
modules.git.enable = true;
|
||||||
|
modules.toolkit.enable = true;
|
||||||
|
|
||||||
|
environment.systemPackages = with pkgs; [
|
||||||
|
pciutils
|
||||||
|
smartmontools
|
||||||
|
usbutils
|
||||||
|
];
|
||||||
|
|
||||||
|
time.timeZone = "America/New_York";
|
||||||
|
i18n.defaultLocale = "en_GB.UTF-8";
|
||||||
|
}
|
||||||
32
hosts/pikachu/disk.nix
Normal file
32
hosts/pikachu/disk.nix
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{ ... }:
|
||||||
|
# pikachu's install layout for disko: one NVMe boot disk with an EFI system partition and ext4 root.
|
||||||
|
# The existing 8 TB ZFS mirror is imported by name and is never declared here.
|
||||||
|
{
|
||||||
|
disko.devices.disk.main = {
|
||||||
|
type = "disk";
|
||||||
|
device = "/dev/nvme0n1";
|
||||||
|
content = {
|
||||||
|
type = "gpt";
|
||||||
|
partitions = {
|
||||||
|
ESP = {
|
||||||
|
size = "2G";
|
||||||
|
type = "EF00";
|
||||||
|
content = {
|
||||||
|
type = "filesystem";
|
||||||
|
format = "vfat";
|
||||||
|
mountpoint = "/boot";
|
||||||
|
mountOptions = [ "umask=0077" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
root = {
|
||||||
|
size = "100%";
|
||||||
|
content = {
|
||||||
|
type = "filesystem";
|
||||||
|
format = "ext4";
|
||||||
|
mountpoint = "/";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
18
hosts/pikachu/hardware-configuration.nix
Normal file
18
hosts/pikachu/hardware-configuration.nix
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{ lib, modulesPath, ... }:
|
||||||
|
# Hardware detected from the Proxmox inventory for this machine.
|
||||||
|
# disko derives the root disk filesystems, none declared here.
|
||||||
|
{
|
||||||
|
imports = [ (modulesPath + "/installer/scan/not-detected.nix") ];
|
||||||
|
|
||||||
|
boot.initrd.availableKernelModules = [
|
||||||
|
"ahci"
|
||||||
|
"nvme"
|
||||||
|
"sd_mod"
|
||||||
|
"xhci_pci"
|
||||||
|
];
|
||||||
|
boot.initrd.kernelModules = [ ];
|
||||||
|
boot.kernelModules = [ "kvm-intel" ];
|
||||||
|
boot.extraModulePackages = [ ];
|
||||||
|
|
||||||
|
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
|
||||||
|
}
|
||||||
1
hosts/pikachu/ssh_host_ed25519_key.pub
Normal file
1
hosts/pikachu/ssh_host_ed25519_key.pub
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKljRf4pJO+pqEqjpPz08gOYq3g1PpxvE66xVw7uMEnA root@pikachu
|
||||||
1
hosts/pikachu/ssh_host_rsa_key.pub
Normal file
1
hosts/pikachu/ssh_host_rsa_key.pub
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCy/riwm7dflA3mT+3a0/2CIoS2LbAsK/vn35kOoNeuzn0yhiF+imexP6tkB3S2t+H5ybRzkbbuNZcynFfeCqthFc8kvbdCnt8Diqoeg96fZ6ecvh5QE5yH9op8534EySetZ/exakFLnF+6EiWMuWUW3DFwsc2kcgDJObqSE8gTx/d7JK953MiTFmSJBFyg1RtQ3ZnMT+iCrvY2dyCLQai7VeF8koVKF2c0leAq2Hc75rb/L9md8MoJa64iPiz7hwTCin3xoFyaY/5hNVvyqFd5PivgR69gLdJkuVsUYO2mJzhur8cYmJD+pGjJ0U45hyE9TMrCFjeJHHuvSt3+2kph62wv95jLNk0WmMlwgyunISxENCSVVtNYdBMXhUh8VhEAW17QpVUg9EnPvxOdTKEjrvfOZYASWUa51JKbgBgexVgFbxdjDZR88DZa31AVBts/cx/59gXTUahFXMYLdZgssx+5uibZQWnvCyfUV9WLbfmK1lgL6hzReg1VkQ87iGr6skjtQYemJxRaFNA1+Q5f3kmG3KncuK/594a3qXYP4gC6A2blf8om1YZ4aXXh6f+GFKLjoEw1vvM2rJ+rjzfymwDX+pxVQ9L13OEtVZc9Ez76pOkbm1hqdbL0gY45+0cpxodhWV0wMQJBDXL1MHP8qcs+/vw0GxVK5l1SnWBGlw== root@pikachu
|
||||||
421
lib.nix
Normal file
421
lib.nix
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
{
|
||||||
|
lib,
|
||||||
|
inputs,
|
||||||
|
self,
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
inherit (lib)
|
||||||
|
attrNames
|
||||||
|
filterAttrs
|
||||||
|
genAttrs
|
||||||
|
flatten
|
||||||
|
hasSuffix
|
||||||
|
mapAttrsToList
|
||||||
|
;
|
||||||
|
|
||||||
|
# Recursively collect every `.nix` file under `dir` as a flat list, for a
|
||||||
|
# module's `imports`.
|
||||||
|
collectNixFiles =
|
||||||
|
dir:
|
||||||
|
flatten (
|
||||||
|
mapAttrsToList (
|
||||||
|
name: type:
|
||||||
|
let
|
||||||
|
path = dir + "/${name}";
|
||||||
|
in
|
||||||
|
if type == "directory" then
|
||||||
|
collectNixFiles path
|
||||||
|
else if type == "regular" && hasSuffix ".nix" name then
|
||||||
|
[ path ]
|
||||||
|
else
|
||||||
|
[ ]
|
||||||
|
) (builtins.readDir dir)
|
||||||
|
);
|
||||||
|
|
||||||
|
# The special arguments every configuration is evaluated with, host and guest
|
||||||
|
# interior alike.
|
||||||
|
specialArgs = {
|
||||||
|
inherit inputs;
|
||||||
|
my = self.lib;
|
||||||
|
};
|
||||||
|
|
||||||
|
# The name of a tagged VLAN's bridge, kept here as the one definition of a
|
||||||
|
# convention shared across the flake.
|
||||||
|
bridgeName = id: "br-vlan${toString id}";
|
||||||
|
|
||||||
|
# A guest with no operator-set MAC derives a stable one from its namespace path.
|
||||||
|
# The first octet 02 marks the address locally-administered and unicast.
|
||||||
|
# The rest is a slice of the path's hash.
|
||||||
|
# The same guest therefore always lands on the same address, which the operator can reserve at the router.
|
||||||
|
deriveMac =
|
||||||
|
name:
|
||||||
|
let
|
||||||
|
hash = builtins.hashString "sha256" name;
|
||||||
|
octet = i: builtins.substring (i * 2) 2 hash;
|
||||||
|
in
|
||||||
|
lib.concatStringsSep ":" ([ "02" ] ++ map octet [ 0 1 2 3 4 ]);
|
||||||
|
|
||||||
|
# Build one host: every module and every guest is imported unconditionally
|
||||||
|
# (inert until its `enable` flag is set), alongside chaotic, the host base,
|
||||||
|
# and the host's own directory.
|
||||||
|
mkHost =
|
||||||
|
{
|
||||||
|
hostName,
|
||||||
|
system ? "x86_64-linux",
|
||||||
|
}:
|
||||||
|
inputs.nixpkgs.lib.nixosSystem {
|
||||||
|
inherit system specialArgs;
|
||||||
|
modules =
|
||||||
|
(collectNixFiles (self + "/modules"))
|
||||||
|
++ (collectNixFiles (self + "/guests"))
|
||||||
|
++ [
|
||||||
|
inputs.chaotic.nixosModules.default
|
||||||
|
inputs.disko.nixosModules.disko
|
||||||
|
inputs.sops-nix.nixosModules.sops
|
||||||
|
inputs.stylix.nixosModules.stylix
|
||||||
|
(self + "/system.nix")
|
||||||
|
(self + "/hosts/${hostName}")
|
||||||
|
{ networking.hostName = hostName; }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
# Build a guest: a module-shaped definition whose body realizes its interior
|
||||||
|
# as a nested container standing on the guest-base, keyed by its namespace path.
|
||||||
|
# `name` is the dotted namespace under `guests.` and `interior` is an extra
|
||||||
|
# module merged into the container alongside the guest-base.
|
||||||
|
guest =
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
interior ? { },
|
||||||
|
}:
|
||||||
|
{ config, lib, ... }:
|
||||||
|
let
|
||||||
|
optionPath = [ "guests" ] ++ lib.splitString "." name;
|
||||||
|
cfg = lib.getAttrFromPath optionPath config;
|
||||||
|
machineName = lib.replaceStrings [ "." ] [ "-" ] name;
|
||||||
|
|
||||||
|
networked = cfg.vlan != null;
|
||||||
|
|
||||||
|
# Host paths the operator maps into the guest, keyed by their in-guest path.
|
||||||
|
userMounts = lib.mapAttrs (_guestPath: m: {
|
||||||
|
inherit (m) hostPath;
|
||||||
|
isReadOnly = m.readOnly;
|
||||||
|
}) cfg.mounts;
|
||||||
|
|
||||||
|
# Each named secret bind-mounted read-only at the same `/run/secrets/<name>`
|
||||||
|
# path it holds on the host.
|
||||||
|
# No ownership is set here, since the container's one-to-one identity map
|
||||||
|
# carries the host file's owner through unchanged.
|
||||||
|
secretMounts = lib.listToAttrs (
|
||||||
|
map (
|
||||||
|
name:
|
||||||
|
let
|
||||||
|
path = config.sops.secrets.${name}.path;
|
||||||
|
in
|
||||||
|
lib.nameValuePair path {
|
||||||
|
hostPath = path;
|
||||||
|
isReadOnly = true;
|
||||||
|
}
|
||||||
|
) cfg.secrets
|
||||||
|
);
|
||||||
|
|
||||||
|
# An in-guest path claimed by both a mount and a secret, which the merge
|
||||||
|
# below would otherwise resolve silently in the secret's favour.
|
||||||
|
mountCollisions = lib.attrNames (builtins.intersectAttrs userMounts secretMounts);
|
||||||
|
|
||||||
|
# The resource caps the operator places on the guest's unit, dropping any
|
||||||
|
# left unset so systemd keeps its uncapped default for those.
|
||||||
|
limitConfig = lib.filterAttrs (_: v: v != null) {
|
||||||
|
MemoryMax = cfg.limits.memory;
|
||||||
|
CPUQuota = cfg.limits.cpu;
|
||||||
|
TasksMax = cfg.limits.tasksMax;
|
||||||
|
};
|
||||||
|
|
||||||
|
# A networked guest owns its bridged interface through its own networkd, the only stable MAC pin for a nested container.
|
||||||
|
# The interface is eth0, the name a nested container gives its bridged veth.
|
||||||
|
# It takes the placement MAC, and the static address or DHCP when that is unset.
|
||||||
|
guestNet =
|
||||||
|
{ lib, ... }:
|
||||||
|
{
|
||||||
|
config = lib.mkIf networked {
|
||||||
|
networking.useNetworkd = true;
|
||||||
|
|
||||||
|
# networkd default-enables resolved, which owns the guest's resolv.conf.
|
||||||
|
# The nested-container default of inheriting the host's file conflicts with that, so the guest keeps its own.
|
||||||
|
networking.useHostResolvConf = false;
|
||||||
|
|
||||||
|
systemd.network.networks."20-eth0" = {
|
||||||
|
matchConfig.Name = "eth0";
|
||||||
|
linkConfig.MACAddress = cfg.mac;
|
||||||
|
networkConfig = lib.mkIf (cfg.address == null) { DHCP = "yes"; };
|
||||||
|
address = lib.mkIf (cfg.address != null) [ cfg.address ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options = lib.setAttrByPath optionPath {
|
||||||
|
enable = lib.mkEnableOption "the ${name} guest, run in its own nested container";
|
||||||
|
backend = lib.mkOption {
|
||||||
|
type = lib.types.enum [
|
||||||
|
"container"
|
||||||
|
"microvm"
|
||||||
|
];
|
||||||
|
default = "container";
|
||||||
|
description = ''
|
||||||
|
How the guest is realized. `container` runs the guest as a
|
||||||
|
systemd-nspawn nested container. `microvm` is reserved for a future
|
||||||
|
hard-isolation backend and is not built yet.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
vlan = lib.mkOption {
|
||||||
|
type = lib.types.nullOr (lib.types.ints.between 1 4094);
|
||||||
|
default = null;
|
||||||
|
example = 10;
|
||||||
|
description = ''
|
||||||
|
The tagged VLAN this guest lives on. The guest attaches to its host's
|
||||||
|
`br-vlan<id>` bridge for that VLAN. Left null, the guest keeps a
|
||||||
|
private network with no bridge attachment. The id must be one of the
|
||||||
|
host's `modules.network.vlans`.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
mac = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = deriveMac name;
|
||||||
|
defaultText = lib.literalMD "a stable address derived from the guest's namespace path";
|
||||||
|
example = "bc:24:11:00:00:01";
|
||||||
|
description = ''
|
||||||
|
The guest's MAC address on its VLAN, pinned inside the guest by its
|
||||||
|
own networkd. Set it to reuse an existing address so a router's DHCP
|
||||||
|
reservation keeps working. Left unset, a stable address is derived
|
||||||
|
from the guest's namespace path in the locally-administered range.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
address = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
default = null;
|
||||||
|
example = "10.0.10.5/24";
|
||||||
|
description = ''
|
||||||
|
The guest's static address, in CIDR form, on its VLAN. Left null, the
|
||||||
|
guest takes its address by DHCP, keeping IP management at the router.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
mounts = lib.mkOption {
|
||||||
|
type = lib.types.attrsOf (
|
||||||
|
lib.types.submodule {
|
||||||
|
options = {
|
||||||
|
hostPath = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
example = "/srv/media";
|
||||||
|
description = "The path on the host bind-mounted into the guest.";
|
||||||
|
};
|
||||||
|
readOnly = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = false;
|
||||||
|
description = ''
|
||||||
|
Mount the path read-only. Read-write by default, since a
|
||||||
|
service must write to the pool data it owns.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
default = { };
|
||||||
|
example = lib.literalExpression ''
|
||||||
|
{
|
||||||
|
"/data/media" = { hostPath = "/srv/media"; };
|
||||||
|
"/data/config" = {
|
||||||
|
hostPath = "/srv/config/jellyfin";
|
||||||
|
readOnly = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
'';
|
||||||
|
description = ''
|
||||||
|
Host paths bind-mounted into the guest, keyed by the path they appear
|
||||||
|
at inside the guest, so a guest sees exactly the data it should at any
|
||||||
|
granularity — a single folder or a whole pool. Each mount is
|
||||||
|
read-write unless `readOnly` is set.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
secrets = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
default = [ ];
|
||||||
|
example = [ "jellyfin-api-key" ];
|
||||||
|
description = ''
|
||||||
|
Names of the secrets this guest needs. The host is the sole
|
||||||
|
decryptor: it decrypts each named secret from its own sops files and
|
||||||
|
bind-mounts the plaintext file into the guest read-only at
|
||||||
|
`/run/secrets/<name>`, the same path it would occupy on a host, so a
|
||||||
|
service reads its credentials at a predictable location. The guest
|
||||||
|
names the files it wants and receives exactly those. It holds no age
|
||||||
|
key and decrypts nothing itself. Ownership carries across unchanged,
|
||||||
|
since the container maps ids one to one, so a secret owned by a uid on
|
||||||
|
the host is owned by that same uid inside the guest.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
limits = {
|
||||||
|
memory = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
default = null;
|
||||||
|
example = "2G";
|
||||||
|
description = ''
|
||||||
|
Cap on the guest's memory, applied to its unit as `MemoryMax`.
|
||||||
|
Accepts systemd size suffixes such as `512M` or `2G`. Left null,
|
||||||
|
the guest's memory is uncapped.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
cpu = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
default = null;
|
||||||
|
example = "150%";
|
||||||
|
description = ''
|
||||||
|
Cap on the guest's CPU, applied to its unit as `CPUQuota`, where
|
||||||
|
`100%` is one full core. Left null, the guest's CPU is uncapped.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
tasksMax = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.ints.positive;
|
||||||
|
default = null;
|
||||||
|
example = 512;
|
||||||
|
description = ''
|
||||||
|
Cap on the number of processes and threads the guest may spawn,
|
||||||
|
applied to its unit as `TasksMax`. Left null, the task count is
|
||||||
|
uncapped.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
nesting = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = false;
|
||||||
|
description = ''
|
||||||
|
Grant the guest's interior the prerequisites to run Podman or other
|
||||||
|
OCI containers of its own. Off by default, so a guest cannot nest
|
||||||
|
containers. On, the guest's container gains the network-administration
|
||||||
|
capability its container runtime uses to build bridges and firewall
|
||||||
|
rules, along with the tun and fuse device nodes such a runtime reaches
|
||||||
|
for, so the interior's `virtualisation.oci-containers` works with
|
||||||
|
Podman as its default runtime.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
autoStart = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = true;
|
||||||
|
description = ''
|
||||||
|
Start the guest at boot. On by default. Disabled, the guest stays
|
||||||
|
defined and can be started on demand, but does not come up at boot.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
# Declared here so the host is the one that decrypts each named secret.
|
||||||
|
# The guest carries no age key and decrypts nothing of its own.
|
||||||
|
sops.secrets = lib.genAttrs cfg.secrets (_: { });
|
||||||
|
|
||||||
|
assertions = [
|
||||||
|
{
|
||||||
|
assertion = mountCollisions == [ ];
|
||||||
|
message = ''
|
||||||
|
guests.${name} maps a mount at ${lib.concatStringsSep ", " mountCollisions}, colliding with a secret bind-mounted at the same path. Rename the mount or the secret so each in-guest path is used once.
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion = cfg.backend == "container";
|
||||||
|
message = ''
|
||||||
|
guests.${name}.backend = "${cfg.backend}" is not implemented. Only the "container" backend is built; "microvm" is reserved for future work.
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion = !networked || lib.elem cfg.vlan config.modules.network.vlans;
|
||||||
|
message = ''
|
||||||
|
guests.${name}.vlan = ${toString cfg.vlan} is not among its host's modules.network.vlans (${lib.concatMapStringsSep ", " toString config.modules.network.vlans}). Declare the VLAN on the host or correct the guest's placement.
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
# The operator's resource caps land on the guest's own unit, which a
|
||||||
|
# networked guest also orders after the bridge its veth enslaves to at
|
||||||
|
# start, since the container backend orders the unit after the network
|
||||||
|
# is up but not after that specific bridge existing.
|
||||||
|
systemd.services."container@${machineName}" = lib.mkIf (cfg.backend == "container") (
|
||||||
|
lib.mkMerge [
|
||||||
|
{ serviceConfig = limitConfig; }
|
||||||
|
(lib.mkIf networked (
|
||||||
|
let
|
||||||
|
bridgeDevice = "sys-subsystem-net-devices-${lib.replaceStrings [ "-" ] [ "\\x2d" ] (bridgeName cfg.vlan)}.device";
|
||||||
|
in
|
||||||
|
{
|
||||||
|
after = [ bridgeDevice ];
|
||||||
|
wants = [ bridgeDevice ];
|
||||||
|
}
|
||||||
|
))
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
containers.${machineName} = lib.mkIf (cfg.backend == "container") {
|
||||||
|
autoStart = cfg.autoStart;
|
||||||
|
|
||||||
|
# The guest gets its own network namespace, so its services — its own
|
||||||
|
# sshd included — never contend with the host's.
|
||||||
|
privateNetwork = lib.mkDefault true;
|
||||||
|
|
||||||
|
# A networked guest's veth is enslaved to the VLAN's bridge, making it
|
||||||
|
# a first-class L2 citizen on that segment.
|
||||||
|
hostBridge = lib.mkIf networked (bridgeName cfg.vlan);
|
||||||
|
|
||||||
|
# The container shares the host's uid and gid space one to one.
|
||||||
|
# A guest process writing as the shared storage group then lands on a bind-mounted pool as that same group, with no permission juggling.
|
||||||
|
# A private-user mapping would shift the ids and reintroduce those errors, so it stays off.
|
||||||
|
privateUsers = lib.mkDefault "no";
|
||||||
|
|
||||||
|
# A nesting guest runs Podman or other OCI containers in its interior.
|
||||||
|
# The network-administration capability lets that runtime build its
|
||||||
|
# bridges and firewall rules.
|
||||||
|
# The tun and fuse device nodes are what it reaches for to network
|
||||||
|
# those containers and back their overlay storage.
|
||||||
|
# The remaining prerequisite, a delegated cgroup subtree for the
|
||||||
|
# runtime to manage, the container backend already grants every guest.
|
||||||
|
additionalCapabilities = lib.optionals cfg.nesting [ "CAP_NET_ADMIN" ];
|
||||||
|
allowedDevices = lib.optionals cfg.nesting [
|
||||||
|
{
|
||||||
|
node = "/dev/net/tun";
|
||||||
|
modifier = "rwm";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
node = "/dev/fuse";
|
||||||
|
modifier = "rwm";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
bindMounts = userMounts // secretMounts;
|
||||||
|
|
||||||
|
inherit specialArgs;
|
||||||
|
|
||||||
|
config = {
|
||||||
|
imports = [
|
||||||
|
(self + "/guest.nix")
|
||||||
|
guestNet
|
||||||
|
interior
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Discover every host (a subdirectory of `hostsDir`) and build each one.
|
||||||
|
mkHosts =
|
||||||
|
hostsDir:
|
||||||
|
let
|
||||||
|
hostNames = attrNames (filterAttrs (_name: type: type == "directory") (builtins.readDir hostsDir));
|
||||||
|
in
|
||||||
|
genAttrs hostNames (hostName: mkHost { inherit hostName; });
|
||||||
|
in
|
||||||
|
{
|
||||||
|
inherit
|
||||||
|
collectNixFiles
|
||||||
|
mkHost
|
||||||
|
mkHosts
|
||||||
|
guest
|
||||||
|
bridgeName
|
||||||
|
;
|
||||||
|
}
|
||||||
32
modules/agents/claude-code/authentication.md
Normal file
32
modules/agents/claude-code/authentication.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Authenticating Claude Code without a browser
|
||||||
|
|
||||||
|
`neogaia` is driven from the console and over SSH, where no local browser can service Claude Code's default OAuth redirect.
|
||||||
|
Either of the two paths below signs the CLI in from a bare terminal.
|
||||||
|
Both are one-time actions per machine; the credentials land under `~/.claude`, which home-manager does not overwrite.
|
||||||
|
|
||||||
|
## Paste-code flow (Claude subscription or Console OAuth)
|
||||||
|
|
||||||
|
Run `claude` and start the login with the `/login` command (the first run offers it automatically).
|
||||||
|
On a machine with no browser it cannot open the authorization page itself, so it prints the authorization URL and waits.
|
||||||
|
|
||||||
|
1. Copy the printed URL to a browser on any other device (phone, another laptop).
|
||||||
|
2. Sign in and approve the request there.
|
||||||
|
3. The page returns a short authorization code; paste it back at the `claude` prompt still waiting in the terminal.
|
||||||
|
|
||||||
|
The session then completes and the token is stored, so later runs need no further login.
|
||||||
|
Because the URL is opened on a *different* device, this works unchanged over SSH.
|
||||||
|
|
||||||
|
## API key
|
||||||
|
|
||||||
|
For non-interactive use, set an Anthropic API key from <https://console.anthropic.com> in the environment before launching `claude`:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ export ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
$ claude
|
||||||
|
```
|
||||||
|
|
||||||
|
Claude Code reads `ANTHROPIC_API_KEY` on startup and skips the interactive login entirely, so this path needs neither a browser nor the paste-code exchange.
|
||||||
|
Usage is billed to the Console account that owns the key rather than to a Claude subscription.
|
||||||
|
|
||||||
|
The key is a secret and is deliberately not baked into this configuration.
|
||||||
|
Export it from the shell for a one-off, or source it from a secret store once one exists on the Host.
|
||||||
65
modules/agents/claude-code/claude-code.nix
Normal file
65
modules/agents/claude-code/claude-code.nix
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
# Claude Code for the primary user, configured through home-manager, which ships
|
||||||
|
# the package and manages ~/.claude.
|
||||||
|
# Login credentials are left unmanaged so they survive rebuilds.
|
||||||
|
let
|
||||||
|
cfg = config.modules.agents.claude-code;
|
||||||
|
user = config.user.name;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.modules.agents.claude-code.enable = lib.mkEnableOption ''
|
||||||
|
Claude Code, Anthropic's CLI, configured via home-manager.
|
||||||
|
|
||||||
|
Enabling this also widens sudo's credential cache, keying it per user rather
|
||||||
|
than per terminal and holding it for 60 minutes, so that a single
|
||||||
|
authentication covers commands the agent issues. No command is made
|
||||||
|
passwordless, but any process running as the primary user can spend the
|
||||||
|
cached credential while it lasts. Suitable for a single-user machine'';
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
# Key the credential cache per user rather than per terminal, so one
|
||||||
|
# authentication covers the agent's terminal-less commands.
|
||||||
|
security.sudo.extraConfig = ''
|
||||||
|
Defaults timestamp_type=global
|
||||||
|
Defaults timestamp_timeout=60
|
||||||
|
'';
|
||||||
|
|
||||||
|
home-manager.users.${user} = {
|
||||||
|
# jq parses the tool input handed to the sudo guard hook.
|
||||||
|
home.packages = [ pkgs.jq ];
|
||||||
|
|
||||||
|
programs.claude-code = {
|
||||||
|
enable = true;
|
||||||
|
|
||||||
|
# One directory per skill, symlinked under ~/.claude/skills.
|
||||||
|
skills = ./skills;
|
||||||
|
|
||||||
|
# Installed under ~/.claude/hooks, referenced by the settings below.
|
||||||
|
hooks."agent-sudo-guard.sh" = builtins.readFile ./hooks/agent-sudo-guard.sh;
|
||||||
|
|
||||||
|
settings = {
|
||||||
|
model = "opus";
|
||||||
|
hooks = {
|
||||||
|
PreToolUse = [
|
||||||
|
{
|
||||||
|
matcher = "Bash";
|
||||||
|
hooks = [
|
||||||
|
{
|
||||||
|
type = "command";
|
||||||
|
command = "~/.claude/hooks/agent-sudo-guard.sh";
|
||||||
|
timeout = 10;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
29
modules/agents/claude-code/hooks/agent-sudo-guard.sh
Executable file
29
modules/agents/claude-code/hooks/agent-sudo-guard.sh
Executable file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Refuse a privileged command while sudo's credential cache is cold, naming the
|
||||||
|
# command that warms it.
|
||||||
|
#
|
||||||
|
# Commands arrive here from subprocesses holding no terminal, so an uncached
|
||||||
|
# sudo fails with a bare non-zero exit and no output, reading as an unexplained stall.
|
||||||
|
# The probe below reads a cache keyed per user rather than per terminal,
|
||||||
|
# so an authentication made in the operator's own terminal counts.
|
||||||
|
|
||||||
|
input=$(cat)
|
||||||
|
command=$(printf '%s' "$input" | jq -r '.tool_input.command // ""')
|
||||||
|
|
||||||
|
# Anchored to a command position so a `sudo` appearing as an argument or inside
|
||||||
|
# a string does not trip the guard.
|
||||||
|
if ! printf '%s' "$command" | grep -qE '(^|[;&|(]|&&|\|\|)[[:space:]]*sudo([[:space:]]|$)'; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if sudo -n true 2>/dev/null; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Exit 2 blocks the call and feeds stderr back to the agent.
|
||||||
|
echo 'Blocked: sudo has no cached credential, and this command cannot answer a password prompt.
|
||||||
|
Ask the operator to run `sudo -v` in their own terminal, then retry.
|
||||||
|
Never attempt to supply a password directly.
|
||||||
|
If this still blocks immediately after the operator runs `sudo -v`, the cache is
|
||||||
|
not the cause: check that this hook can reach sudo at all.' >&2
|
||||||
|
exit 2
|
||||||
@@ -23,11 +23,11 @@ If the request describes a new workflow, capability, or repeated manual process
|
|||||||
- Is there already a word — in your prompts, docs, or codebase — that names this behavior? Reach for that **leading word** before coining one.
|
- Is there already a word — in your prompts, docs, or codebase — that names this behavior? Reach for that **leading word** before coining one.
|
||||||
Done when every axis above has an answer, or the user says to just draft something and iterate.
|
Done when every axis above has an answer, or the user says to just draft something and iterate.
|
||||||
|
|
||||||
2. **Write the draft.** First decide where it lives: project-local `.claude/skills/` if the workflow is tied to this one repo, `~/.claude/skills/` if it's general-purpose across projects. Then follow the **information hierarchy**: steps for what the agent does in order, in-file **reference** for facts every branch needs, and disclose the rest behind a pointer — to a sibling file, or to the existing project docs identified in step 1 rather than restating them. Done when every branch from step 1 has somewhere to live, and no sentence fails the no-op test in isolation (see `No-Op` in GLOSSARY.md).
|
2. **Write the draft.** First decide where it lives: project-local `.claude/skills/` if the workflow is tied to this one repo, the personal set if it's general-purpose across projects. The personal set is not authored in `~/.claude/skills/` — that tree is generated, and every file under it is a read-only symlink into the Nix store. Write it in the dotfiles repo at `modules/claude-code/skills/<name>/` and rebuild to make it live. Creating files directly under `~/.claude/skills/` looks like it works, because the directories themselves are writable, but the result is untracked by the repo and reaches no other machine. Then follow the **information hierarchy**: steps for what the agent does in order, in-file **reference** for facts every branch needs, and disclose the rest behind a pointer — to a sibling file, or to the existing project docs identified in step 1 rather than restating them. Done when every branch from step 1 has somewhere to live, and no sentence fails the no-op test in isolation (see `No-Op` in GLOSSARY.md).
|
||||||
|
|
||||||
## Audit an existing skill
|
## Audit an existing skill
|
||||||
|
|
||||||
1. **Locate it.** Check the current project's `.claude/skills/`, then `~/.claude/skills/`, then `~/.claude/skills/library/`, in that order; ask if the name is ambiguous across locations. If it's tracked in a project's `skills-lock.yaml`, mention that editing it here will make it read as locally-customized to `update-skills` — confirm that's actually intended rather than editing the library source.
|
1. **Locate it.** Check the current project's `.claude/skills/`, then `~/.claude/skills/`, then `~/.claude/skills/library/`, in that order; ask if the name is ambiguous across locations. A hit under `~/.claude/skills/` is a read-only symlink and cannot be edited in place: its source is the dotfiles repo, at `modules/claude-code/skills/<name>/` for a personal skill or `modules/claude-code/skills/library/<name>/` for a library one. Edit there and rebuild. If it's tracked in a project's `skills-lock.yaml`, mention that editing it here will make it read as locally-customized to `update-skills` — confirm that's actually intended rather than editing the library source.
|
||||||
|
|
||||||
2. **Apply the checklist.** Read the skill and its disclosed files, then check each against GLOSSARY.md, quoting the offending line for anything that fails:
|
2. **Apply the checklist.** Read the skill and its disclosed files, then check each against GLOSSARY.md, quoting the offending line for anything that fails:
|
||||||
- **Premature completion** — is each completion criterion checkable, and does it demand what the step actually needs?
|
- **Premature completion** — is each completion criterion checkable, and does it demand what the step actually needs?
|
||||||
@@ -44,6 +44,6 @@ If the request describes a new workflow, capability, or repeated manual process
|
|||||||
1. Propose one realistic test prompt — reflecting the trigger phrasing gathered (draft) or the skill's existing purpose (audit) — and get it confirmed or adjusted before spending a run on it.
|
1. Propose one realistic test prompt — reflecting the trigger phrasing gathered (draft) or the skill's existing purpose (audit) — and get it confirmed or adjusted before spending a run on it.
|
||||||
2. Spawn one subagent: give it the skill's path and the confirmed prompt, have it attempt the task using the skill, and report back what happened — including anywhere it hesitated, misread the skill, or did something unexpected.
|
2. Spawn one subagent: give it the skill's path and the confirmed prompt, have it attempt the task using the skill, and report back what happened — including anywhere it hesitated, misread the skill, or did something unexpected.
|
||||||
3. Re-read the draft/rewrite against GLOSSARY.md's failure modes in light of that run, and fix whatever either pass turned up. If the fix is substantial, repeat from step 1; otherwise it's done.
|
3. Re-read the draft/rewrite against GLOSSARY.md's failure modes in light of that run, and fix whatever either pass turned up. If the fix is substantial, repeat from step 1; otherwise it's done.
|
||||||
4. Stage the specific changed or created paths — one path per file, never a wildcard — with the host project's own staging convention: plain `git add <path>` normally, or e.g. `dot add <path>` in this dotfiles setup (wrap as `fish -c "dot add <path>"` if the invoking shell isn't fish — `dot` is a fish function, not a binary on `$PATH`). Do not commit; that's left to the user.
|
4. Stage the specific changed or created paths — one path per file, never a wildcard — with `git add <path>`. Do not commit; that's left to the user.
|
||||||
|
|
||||||
Done when the subagent's run succeeded without confusion on the confirmed prompt, the checklist raised nothing outstanding, and every changed path is staged.
|
Done when the subagent's run succeeded without confusion on the confirmed prompt, the checklist raised nothing outstanding, and every changed path is staged.
|
||||||
72
modules/agents/claude-code/skills/implement/SKILL.md
Normal file
72
modules/agents/claude-code/skills/implement/SKILL.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
name: implement
|
||||||
|
description: Implement a task file produced by /to-tasks on its own branch, review it, close it out, and open a PR.
|
||||||
|
disable-model-invocation: true
|
||||||
|
---
|
||||||
|
|
||||||
|
Implement a task file end-to-end: branch, build it, review it, close it out, and open a PR.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
### 1. Read the task file and check blockers
|
||||||
|
|
||||||
|
The user passes the path to a task file (`.claude/tasks/<NNNN>-slug.md`, as produced by `/to-tasks`) explicitly — don't infer one from context.
|
||||||
|
|
||||||
|
If the task's frontmatter has a `blocked-by` field, read each referenced task file and check for any unresolved `- [ ]` acceptance criterion. If any blocker isn't fully resolved, warn the user which one and why, and confirm before proceeding — don't refuse outright.
|
||||||
|
|
||||||
|
### 2. Sync `main` and branch off it
|
||||||
|
|
||||||
|
Switch to `main`, fast-forward it (`git pull --ff-only`), then create and switch to a branch named `task-<NNNN>-<slug>` — taken verbatim from the task file's basename, so `.claude/tasks/0003-issue-view-and-truncation.md` gives `task-0003-issue-view-and-truncation`.
|
||||||
|
Use whatever git invocation the project itself uses; a repo may wrap it.
|
||||||
|
|
||||||
|
Stop and ask the user before going further if:
|
||||||
|
|
||||||
|
- **The working tree has uncommitted changes.** Never stash them automatically.
|
||||||
|
- **`git pull --ff-only` fails.** Local `main` has diverged; report what diverged. Never `reset --hard`.
|
||||||
|
- **The task's `blocked-by` work isn't reachable from `main`.** The blocker's PR is likely unmerged; name it.
|
||||||
|
|
||||||
|
If the task branch already exists, switch to it and carry on — don't recreate it, and don't rebase it onto the freshly pulled `main`.
|
||||||
|
Always branch off `main`, never off a sibling task branch.
|
||||||
|
|
||||||
|
### 3. Implement
|
||||||
|
|
||||||
|
Build the work described in the task's "What to build" section, satisfying its acceptance criteria. Use `/test-driven-development` where possible, at the seams already agreed when the spec or task was written.
|
||||||
|
|
||||||
|
Run typechecking regularly, single test files regularly, and the full test suite once at the end.
|
||||||
|
|
||||||
|
### 4. Stage the changes
|
||||||
|
|
||||||
|
Stage (`git add`) each file you create or modify, specifically — not `git add -A` — so nothing untracked and unrelated gets swept in.
|
||||||
|
|
||||||
|
### 5. Review
|
||||||
|
|
||||||
|
Run `/review-uncommitted`, passing the task file itself as the spec source — it already links back to its parent spec via its `spec` frontmatter field, if any. Address anything it raises before moving on.
|
||||||
|
|
||||||
|
Keep its report — step 7 puts part of it in the PR.
|
||||||
|
|
||||||
|
### 6. Close out the task file
|
||||||
|
|
||||||
|
Mark every acceptance criterion `[x]` if satisfied or `[-]` if deliberately dropped, so none are left `[ ]`. Append a `## Implementation Notes` section explaining any deviations from the plan — dropped criteria (referencing which, and why), scope changes, decisions made mid-implementation, follow-ups worth flagging. Skip the section only if nothing deviated. Leave the `spec` and `blocked-by` frontmatter fields untouched — they're a permanent record, not a checklist to clear (see `to-tasks`'s `TASK-FORMAT.md`).
|
||||||
|
|
||||||
|
Stage the updated task file with the rest.
|
||||||
|
|
||||||
|
### 7. Commit, push, and open a PR
|
||||||
|
|
||||||
|
Make **one** commit for the whole task, code and task file together.
|
||||||
|
Match the repo's existing commit convention — read its recent history or its CLAUDE.md, don't assume one — and reference the task in the subject, e.g. `(task 0003)`.
|
||||||
|
|
||||||
|
Push the branch (`git push -u origin task-<NNNN>-<slug>`) and open a pull request against `main` with the repo's forge CLI: `tea` for Gitea, `gh` for GitHub.
|
||||||
|
Never base the PR on a sibling task branch.
|
||||||
|
Open it ready, not draft.
|
||||||
|
|
||||||
|
The PR body carries:
|
||||||
|
|
||||||
|
- The task file's path.
|
||||||
|
- A short summary of what was built, and any deviations — the same ones just written into `## Implementation Notes`.
|
||||||
|
- A `## Review` section: the `## Risk` block from step 5 verbatim (overall rating plus its six factor lines), then **only** the Standards and Spec findings left unaddressed, each with a one-line reason. Findings that were fixed are already in the diff; leave them out.
|
||||||
|
|
||||||
|
Don't ask for confirmation before pushing or opening the PR.
|
||||||
|
If the repo has no remote, stop after the commit and report that no PR was opened.
|
||||||
|
|
||||||
|
Stay on the task branch when done.
|
||||||
|
Report the branch name, the PR URL, and any unaddressed review findings.
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# HTML Report Format
|
||||||
|
|
||||||
|
The architectural review is rendered as a single self-contained HTML file in the OS temp directory. Tailwind and Mermaid both come from CDNs. Mermaid handles graph-shaped diagrams reliably; hand-built divs and inline SVG handle the more editorial visuals (mass diagrams, cross-sections). Mix the two — don't lean on Mermaid for everything, it'll start to look generic.
|
||||||
|
|
||||||
|
## Scaffold
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Architecture review — {{repository name}}</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script type="module">
|
||||||
|
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
|
||||||
|
mermaid.initialize({ startOnLoad: true, theme: "neutral", securityLevel: "loose" });
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
/* small custom layer for things Tailwind doesn't cover cleanly:
|
||||||
|
dashed seam lines, hand-drawn-feeling arrow heads, etc. */
|
||||||
|
.seam { stroke-dasharray: 4 4; }
|
||||||
|
.leak { stroke: #dc2626; }
|
||||||
|
.deep { background: linear-gradient(135deg, #0f172a, #1e293b); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-stone-50 text-slate-900 font-sans">
|
||||||
|
<main class="max-w-5xl mx-auto px-6 py-12 space-y-12">
|
||||||
|
<header>...</header>
|
||||||
|
<section id="candidates" class="space-y-10">...</section>
|
||||||
|
<section id="top-recommendation">...</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Header
|
||||||
|
|
||||||
|
Repo name, date, and a compact legend: solid box = module, dashed line = seam, red arrow = leakage, thick dark box = deep module. No introduction paragraph — straight into the candidates.
|
||||||
|
|
||||||
|
## Candidate card
|
||||||
|
|
||||||
|
The diagrams carry the weight. Prose is sparse, plain, and uses the glossary terms (from the `/codebase-design` skill) without ceremony.
|
||||||
|
|
||||||
|
Each candidate is one `<article>`:
|
||||||
|
|
||||||
|
- **Title** — short, names the deepening (e.g. "Collapse the Order intake pipeline").
|
||||||
|
- **Badge row** — recommendation strength (`Strong` = emerald, `Worth exploring` = amber, `Speculative` = slate), plus a tag for the dependency category (`in-process`, `local-substitutable`, `ports & adapters`, `mock`).
|
||||||
|
- **Files** — monospaced list, `font-mono text-sm`.
|
||||||
|
- **Before / After diagram** — the centrepiece. Two columns, side by side. See patterns below.
|
||||||
|
- **Problem** — one sentence. What hurts.
|
||||||
|
- **Solution** — one sentence. What changes.
|
||||||
|
- **Wins** — bullets, ≤6 words each. e.g. "Tests hit one interface", "Pricing logic stops leaking", "Delete 4 shallow wrappers".
|
||||||
|
- **ADR callout** (if applicable) — one line in an amber-tinted box.
|
||||||
|
|
||||||
|
No paragraphs of explanation. If the diagram needs a paragraph to be understood, redraw the diagram.
|
||||||
|
|
||||||
|
## Diagram patterns
|
||||||
|
|
||||||
|
Pick the pattern that fits the candidate. Mix them. Don't make every diagram look the same — variety is part of the point.
|
||||||
|
|
||||||
|
### Mermaid graph (the workhorse for dependencies / call flow)
|
||||||
|
|
||||||
|
Use a Mermaid `flowchart` or `graph` when the point is "X calls Y calls Z, and look at the mess." Wrap it in a Tailwind-styled card so it doesn't feel parachuted in. Style with classDef to colour leakage edges red and the deep module dark. Sequence diagrams work well for "before: 6 round-trips; after: 1."
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="rounded-lg border border-slate-200 bg-white p-4">
|
||||||
|
<pre class="mermaid">
|
||||||
|
flowchart LR
|
||||||
|
A[OrderHandler] --> B[OrderValidator]
|
||||||
|
B --> C[OrderRepo]
|
||||||
|
C -.leak.-> D[PricingClient]
|
||||||
|
classDef leak stroke:#dc2626,stroke-width:2px;
|
||||||
|
class C,D leak
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hand-built boxes-and-arrows (when Mermaid's layout fights you)
|
||||||
|
|
||||||
|
Modules as `<div>`s with borders and labels. Arrows as inline SVG `<line>` or `<path>` elements positioned absolutely over a relative container. Reach for this when you want the "after" diagram to feel like one thick-bordered deep module with greyed-out internals — Mermaid won't render that with the right weight.
|
||||||
|
|
||||||
|
### Cross-section (good for layered shallowness)
|
||||||
|
|
||||||
|
Stack horizontal bands (`h-12 border-l-4`) to show layers a call passes through. Before: 6 thin layers each doing nothing. After: 1 thick band labelled with the consolidated responsibility.
|
||||||
|
|
||||||
|
### Mass diagram (good for "interface as wide as implementation")
|
||||||
|
|
||||||
|
Two rectangles per module — one for interface surface area, one for implementation. Before: interface rectangle is nearly as tall as the implementation rectangle (shallow). After: interface rectangle is short, implementation rectangle is tall (deep).
|
||||||
|
|
||||||
|
### Call-graph collapse
|
||||||
|
|
||||||
|
Before: a tree of function calls rendered as nested boxes. After: the same tree collapsed into one box, with the now-internal calls shown faded inside it.
|
||||||
|
|
||||||
|
## Style guidance
|
||||||
|
|
||||||
|
- Lean editorial, not corporate-dashboard. Generous whitespace. Serif optional for headings (`font-serif` works well with stone/slate).
|
||||||
|
- Colour sparingly: one accent (emerald or indigo) plus red for leakage and amber for warnings.
|
||||||
|
- Keep diagrams ~320px tall so before/after sits comfortably side by side without scrolling.
|
||||||
|
- Use `text-xs uppercase tracking-wider` for module labels inside diagrams — they should read as schematic, not as UI.
|
||||||
|
- The only scripts are the Tailwind CDN and the Mermaid ESM import. The report is otherwise static — no app code, no interactivity beyond Mermaid's own rendering.
|
||||||
|
|
||||||
|
## Top recommendation section
|
||||||
|
|
||||||
|
One larger card. Candidate name, one sentence on why, anchor link to its card. That's it.
|
||||||
|
|
||||||
|
## Tone
|
||||||
|
|
||||||
|
Plain English, concise — but the architectural nouns and verbs come straight from the `/codebase-design` glossary, terms and exclusions alike. Concision is not an excuse to drift.
|
||||||
|
|
||||||
|
**Phrasings that fit the style:**
|
||||||
|
|
||||||
|
- "Order intake module is shallow — interface nearly matches the implementation."
|
||||||
|
- "Pricing leaks across the seam."
|
||||||
|
- "Deepen: one interface, one place to test."
|
||||||
|
- "Two adapters justify the seam: HTTP in prod, in-memory in tests."
|
||||||
|
|
||||||
|
**Wins bullets** name the gain in glossary terms: *"locality: bugs concentrate in one module"*, *"leverage: one interface, N call sites"*, *"interface shrinks; implementation absorbs the wrappers"*. Don't write *"easier to maintain"* or *"cleaner code"* — those terms aren't in the glossary and don't earn their place.
|
||||||
|
|
||||||
|
No hedging, no throat-clearing, no "it's worth noting that…". If a sentence could be a bullet, make it a bullet. If a bullet could be cut, cut it. If a term isn't in the `/codebase-design` glossary, reach for one that is before inventing a new one.
|
||||||
68
modules/agents/claude-code/skills/improve-codebase/SKILL.md
Normal file
68
modules/agents/claude-code/skills/improve-codebase/SKILL.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
name: improve-codebase
|
||||||
|
description: Scan a codebase for deepening opportunities, present them as a visual HTML report, then grill through whichever one you pick.
|
||||||
|
disable-model-invocation: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Improve Codebase
|
||||||
|
|
||||||
|
Surface architectural friction and propose **deepening opportunities** — refactors that turn shallow modules into deep ones. The aim is testability and AI-navigability.
|
||||||
|
|
||||||
|
This command is _informed_ by the project's domain model and built on a shared design vocabulary:
|
||||||
|
|
||||||
|
- Run the `/codebase-design` skill for the architecture vocabulary (**module**, **interface**, **depth**, **seam**, **adapter**, **leverage**, **locality**) and its principles (the deletion test, "the interface is the test surface", "one adapter = hypothetical seam, two = real"). Use its terms exactly in every suggestion, per its glossary.
|
||||||
|
- The domain language in `.claude/CONTEXT.md` gives names to good seams; ADRs in `.claude/adr/` record decisions this command should not re-litigate.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
### 1. Explore
|
||||||
|
|
||||||
|
Read the project's domain glossary (`.claude/CONTEXT.md`) and any ADRs in the area you're touching first.
|
||||||
|
|
||||||
|
Then use the Agent tool with `subagent_type=Explore` to walk every top-level module or directory in scope (the whole repository, or the area the user pointed you to) — even if only briefly for the ones that turn out clean. Within each, judge friction organically rather than against a rigid checklist:
|
||||||
|
|
||||||
|
- Where does understanding one concept require bouncing between many small modules?
|
||||||
|
- Where are modules **shallow** — interface nearly as complex as the implementation?
|
||||||
|
- Where have pure functions been extracted just for testability, but the real bugs hide in how they're called (no **locality**)?
|
||||||
|
- Where do tightly-coupled modules leak across their seams?
|
||||||
|
- Which parts of the codebase are untested, or hard to test through their current interface?
|
||||||
|
|
||||||
|
Apply the **deletion test** to anything you suspect is shallow: would deleting it concentrate complexity, or just move it? A "yes, concentrates" is the signal you want.
|
||||||
|
|
||||||
|
Zero candidates is a legitimate outcome for a genuinely clean area — but it has to follow from having looked, not from stopping early.
|
||||||
|
|
||||||
|
### 2. Present candidates as an HTML report
|
||||||
|
|
||||||
|
Write a self-contained HTML file to the OS temp directory so nothing lands in the repository. Resolve the temp dir from `$TMPDIR`, falling back to `/tmp` (or `%TEMP%` on Windows), and write to `<tmpdir>/architecture-review-<timestamp>.html` so each run gets a fresh file. Open it for the user — `xdg-open <path>` on Linux, `open <path>` on macOS, `start <path>` on Windows. Treat the open as best-effort: it's a no-op in a headless/sandboxed environment with no display server, so report the absolute path regardless of whether the open succeeded.
|
||||||
|
|
||||||
|
The report uses **Tailwind via CDN** for layout and styling, and **Mermaid via CDN** for diagrams where a graph/flow/sequence reliably communicates the structure. Mix Mermaid with hand-crafted CSS/SVG visuals — use Mermaid when relationships are graph-shaped (call graphs, dependencies, sequences), and hand-built divs/SVG when you want something more editorial (mass diagrams, cross-sections, collapse animations). Each candidate gets a **before/after visualisation**. Be visual.
|
||||||
|
|
||||||
|
For each candidate, render a card with:
|
||||||
|
|
||||||
|
- **Files** — which files/modules are involved
|
||||||
|
- **Problem** — why the current architecture is causing friction
|
||||||
|
- **Solution** — plain English description of what would change
|
||||||
|
- **Benefits** — explained in terms of locality and leverage, and how tests would improve
|
||||||
|
- **Before / After diagram** — side-by-side, custom-drawn, illustrating the shallowness and the deepening
|
||||||
|
- **Recommendation strength** — one of `Strong`, `Worth exploring`, `Speculative`, rendered as a badge
|
||||||
|
|
||||||
|
End the report with a **Top recommendation** section: which candidate you'd tackle first and why.
|
||||||
|
|
||||||
|
**Use `.claude/CONTEXT.md` vocabulary for the domain, and the `/codebase-design` vocabulary for the architecture.** If `.claude/CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service."
|
||||||
|
|
||||||
|
**ADR conflicts**: if a candidate contradicts an existing ADR, only surface it when the friction is real enough to warrant revisiting the ADR. Mark it clearly in the card (e.g. a warning callout: _"contradicts ADR-0007 — but worth reopening because…"_). Don't list every theoretical refactor an ADR forbids.
|
||||||
|
|
||||||
|
See [HTML-REPORT.md](HTML-REPORT.md) for the full HTML scaffold, diagram patterns, and styling guidance.
|
||||||
|
|
||||||
|
Do NOT propose interfaces yet. After the file is written, ask the user: "Which of these would you like to explore?"
|
||||||
|
|
||||||
|
### 3. Grilling loop
|
||||||
|
|
||||||
|
Once the user picks a candidate, run `/grill` to walk the design tree with them — constraints, dependencies, the shape of the deepened module, what sits behind the seam, what tests survive.
|
||||||
|
|
||||||
|
Side effects happen inline as decisions crystallize — run `/domain-modeling` to keep the domain model current as you go, even if `.claude/CONTEXT.md` doesn't exist yet:
|
||||||
|
|
||||||
|
- **Naming a deepened module after a concept not in `.claude/CONTEXT.md`?** Add the term to `.claude/CONTEXT.md`. Create the file lazily if it doesn't exist.
|
||||||
|
- **Sharpening a fuzzy term during the conversation?** Update `.claude/CONTEXT.md` right there.
|
||||||
|
- **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing — skip ephemeral reasons ("not worth it right now") and self-evident ones.
|
||||||
|
- **Want to explore alternative interfaces for the deepened module?** Run the `/codebase-design` skill and use its design-it-twice parallel sub-agent pattern.
|
||||||
152
modules/agents/claude-code/skills/library/nbdev/SKILL.md
Normal file
152
modules/agents/claude-code/skills/library/nbdev/SKILL.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
---
|
||||||
|
name: nbdev
|
||||||
|
description: nbdev conventions for notebooks — directives, cell structure, docments, tests, execution. Use for any .ipynb operation — including reads — in an nbdev project.
|
||||||
|
---
|
||||||
|
|
||||||
|
# nbdev
|
||||||
|
|
||||||
|
## Tool Preference
|
||||||
|
|
||||||
|
- Use the **Jupyter MCP** for all `.ipynb` operations — read, edit, insert, delete, execute
|
||||||
|
- Do **not** use the built-in `NotebookEdit` tool; it writes cell source as a single JSON string which breaks standard Jupyter formatting and produces noisy diffs
|
||||||
|
- Re-read the notebook before editing if it may have changed since your last read — cell indices/IDs can shift under concurrent edits (e.g. via JupyterLab's real-time collaboration), and editing by a stale index can hit the wrong cell
|
||||||
|
|
||||||
|
## nbdev Directives
|
||||||
|
|
||||||
|
Directives are comments at the top of a cell that control how nbdev processes it:
|
||||||
|
|
||||||
|
- `#| export` — include this cell in the exported Python module and in the docs
|
||||||
|
- `#| hide` — exclude this cell from both the module and the docs
|
||||||
|
- `#| hide_input` — show cell output in docs but hide the source code
|
||||||
|
- `#| default_exp module_name` — set which module this notebook exports to (second cell)
|
||||||
|
- `#| exporti` — export to module but do not show in docs (for internal helpers)
|
||||||
|
- `#| eval: false` — include in docs but do not execute during `nbdev-test`
|
||||||
|
|
||||||
|
Imports needed only for tests or examples should **not** be exported.
|
||||||
|
|
||||||
|
Never hand-edit the exported `.py` module files — they're build artifacts regenerated from the notebook by `nbdev_export`. All edits go through the source notebook in `nbs/`.
|
||||||
|
|
||||||
|
## Notebook Structure
|
||||||
|
|
||||||
|
Every notebook must follow this structure:
|
||||||
|
|
||||||
|
**Cell 1 — Markdown frontmatter:**
|
||||||
|
```markdown
|
||||||
|
# Module Title
|
||||||
|
|
||||||
|
> A one-line description of what this module does
|
||||||
|
```
|
||||||
|
The H1 becomes the page title in docs. The blockquote becomes the subtitle.
|
||||||
|
|
||||||
|
**Cell 2 — Default export:**
|
||||||
|
```python
|
||||||
|
#| default_exp module_name
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body cells** — alternating between exported code, demonstrations, and markdown explanations (see Cell Structure below).
|
||||||
|
|
||||||
|
**Last cell:**
|
||||||
|
```python
|
||||||
|
#| hide
|
||||||
|
import nbdev; nbdev.nbdev_export()
|
||||||
|
```
|
||||||
|
|
||||||
|
Before declaring any notebook task complete, restart the kernel and run all cells top-to-bottom to verify it is fully reproducible.
|
||||||
|
|
||||||
|
## Cell Structure
|
||||||
|
|
||||||
|
Keep cells short. Each exported function gets its own cell, immediately followed by a demonstration. Do not write long functions with comments interspersed — split them into small separate cells with explanations and working examples after each.
|
||||||
|
|
||||||
|
The pattern per concept:
|
||||||
|
|
||||||
|
1. *(Optional)* A markdown cell explaining what comes next
|
||||||
|
2. A `#| export` code cell with the function
|
||||||
|
3. One or more plain code cells demonstrating usage
|
||||||
|
4. Assertions that double as tests
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
#| export
|
||||||
|
def slugify(text: str) -> str:
|
||||||
|
"Convert text to a URL-safe slug"
|
||||||
|
return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
||||||
|
```
|
||||||
|
```python
|
||||||
|
slug = slugify("Hello, World!")
|
||||||
|
assert slug == "hello-world"
|
||||||
|
slug
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docstrings and Parameter Documentation
|
||||||
|
|
||||||
|
Keep docstrings short — a single-line summary is sufficient for most functions. Elaborate in separate markdown or code cells below, where you can use real examples.
|
||||||
|
|
||||||
|
Use **docments** (inline parameter comments) instead of verbose docstring parameter sections:
|
||||||
|
|
||||||
|
```python
|
||||||
|
#| export
|
||||||
|
def greet(
|
||||||
|
name: str, # Person to greet
|
||||||
|
greeting: str="Hi", # Greeting word to use
|
||||||
|
) -> str: # The composed greeting
|
||||||
|
"Compose a greeting for name"
|
||||||
|
return f"{greeting}, {name}!"
|
||||||
|
```
|
||||||
|
|
||||||
|
This renders as a clean parameter table in the docs automatically — no need to repeat type information in the docstring body.
|
||||||
|
|
||||||
|
Use backticks around symbol names in docstrings and markdown — nbdev automatically converts these to hyperlinks to the relevant reference page.
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
- **Prefer composition**: write small functions that do one thing well
|
||||||
|
- Each exported function should be focused enough to fit naturally in a single notebook cell — one cell, one idea
|
||||||
|
- Use type hints on all exported functions
|
||||||
|
- Avoid classes unless state is genuinely needed — prefer functions that take and return data
|
||||||
|
- If you do write a class, use `fastcore`'s `@patch` decorator to define each method in its own cell, immediately followed by a demonstration. This avoids long class definitions and keeps examples close to the code
|
||||||
|
|
||||||
|
When a class is needed, document its methods with `show_doc`:
|
||||||
|
```python
|
||||||
|
from nbdev.showdoc import show_doc
|
||||||
|
show_doc(MyClass.my_method)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Every code cell is run as a test by nbdev unless explicitly marked otherwise — any exception fails the test.
|
||||||
|
|
||||||
|
- Turn demonstrations into tests by adding `assert` statements
|
||||||
|
- Use `fastcore.test` helpers for better error messages:
|
||||||
|
```python
|
||||||
|
from fastcore.test import test_eq, test_fail
|
||||||
|
test_eq(slugify("Hello World"), "hello-world")
|
||||||
|
```
|
||||||
|
- Document expected error cases with `test_fail`:
|
||||||
|
```python
|
||||||
|
test_fail(lambda: slugify(""), contains="empty")
|
||||||
|
```
|
||||||
|
- Each test/demo cell should import what it needs directly — don't rely on a name imported in a later cell just because it happened to be in scope during a prior run
|
||||||
|
|
||||||
|
## Execution
|
||||||
|
|
||||||
|
- Always execute cells after writing them to verify they work
|
||||||
|
- If a cell errors, read the full traceback before attempting a fix — do not guess
|
||||||
|
- When installing packages, use `%pip install` inside the notebook (not `!pip install`) so they install into the running kernel
|
||||||
|
- Use autoreload at the top of notebooks that import from other modules in the project:
|
||||||
|
```python
|
||||||
|
%load_ext autoreload
|
||||||
|
%autoreload 2
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- Use H2 (`##`) markdown cells to group related symbols within a notebook
|
||||||
|
- Use H4 (`####`) markdown cells to split long explanations within a symbol's section (notes, examples, edge cases, etc.)
|
||||||
|
- Add rich representations to classes via `_repr_markdown_` where it aids understanding
|
||||||
|
- Include real code examples, plots, and diagrams — notebooks support rich output, use it
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
- Never print secrets, tokens, passwords, or API keys into cell output — notebook outputs get committed to git and published in docs, unlike transient script output
|
||||||
|
- Prefer summaries over dumping large data structures (`.head()`, `len()`, `[:5]`, etc.)
|
||||||
|
- Large outputs consume context window — keep them concise
|
||||||
37
modules/agents/claude-code/skills/remove-skills/SKILL.md
Normal file
37
modules/agents/claude-code/skills/remove-skills/SKILL.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
name: remove-skills
|
||||||
|
description: Remove one or more previously added library skills from the current project.
|
||||||
|
disable-model-invocation: true
|
||||||
|
---
|
||||||
|
|
||||||
|
Removes a skill that [`setup-skills`](../setup-skills/SKILL.md) previously
|
||||||
|
copied into the current project, deleting both its files and its entry in
|
||||||
|
`.claude/skills-lock.yaml` (see [LOCKFILE.md](../setup-skills/LOCKFILE.md)
|
||||||
|
for its schema).
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Read `.claude/skills-lock.yaml`. If it doesn't exist or is empty, tell
|
||||||
|
the user there's nothing installed to remove and stop.
|
||||||
|
|
||||||
|
2. Determine which skill(s) to remove:
|
||||||
|
- If the user's invocation already named a specific skill, use that —
|
||||||
|
if it isn't in the lockfile, say so and stop.
|
||||||
|
- Otherwise, list every skill currently in the lockfile and ask the
|
||||||
|
user to pick one (or more).
|
||||||
|
|
||||||
|
3. For each skill to remove, compute its current hash
|
||||||
|
(`~/.claude/skills/setup-skills/hash-dir.sh .claude/skills/<name>`)
|
||||||
|
and compare it to the hash stored in the lockfile:
|
||||||
|
- If it matches (never modified since it was installed), delete
|
||||||
|
`.claude/skills/<name>/` and remove its lockfile entry immediately —
|
||||||
|
no confirmation needed, since nothing of the user's is being lost.
|
||||||
|
- If it differs (locally customized), tell the user it has local
|
||||||
|
changes that will be permanently lost and ask for confirmation
|
||||||
|
before deleting. If they decline, leave that skill installed and
|
||||||
|
move on to the next.
|
||||||
|
|
||||||
|
4. Finish with a summary of what was removed and what was left in place.
|
||||||
|
|
||||||
|
Done when every skill to remove has been either deleted (with its lockfile
|
||||||
|
entry removed) or explicitly left in place with a stated reason.
|
||||||
147
modules/agents/claude-code/skills/review-uncommitted/SKILL.md
Normal file
147
modules/agents/claude-code/skills/review-uncommitted/SKILL.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
---
|
||||||
|
name: review-uncommitted
|
||||||
|
description: Review the working tree's uncommitted changes along three axes — change risk, repo standards, and spec fidelity — using parallel sub-agents.
|
||||||
|
---
|
||||||
|
|
||||||
|
Three-axis review of the diff between `HEAD` and the working tree:
|
||||||
|
|
||||||
|
- **Risk** — how much attention does this change warrant, from low to high?
|
||||||
|
- **Standards** — does the code conform to this repo's documented coding standards?
|
||||||
|
- **Spec** — does the code faithfully implement the originating PRD or task file?
|
||||||
|
|
||||||
|
All three axes run as **parallel sub-agents** so they don't pollute each other's context, then this skill aggregates their findings.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
### 1. Capture the diff
|
||||||
|
|
||||||
|
The diff command is `git diff HEAD` — everything uncommitted, staged or not.
|
||||||
|
New files must already be tracked (`git add`ed) to show up; this skill doesn't scan for untracked files, so that's the caller's responsibility.
|
||||||
|
|
||||||
|
Confirm the diff is non-empty before going further.
|
||||||
|
An empty diff should fail here — not inside three parallel sub-agents.
|
||||||
|
|
||||||
|
### 2. Identify the spec source
|
||||||
|
|
||||||
|
Look for the originating spec, in this order:
|
||||||
|
|
||||||
|
1. A path the user passed as an argument.
|
||||||
|
2. A spec file matching the branch name or feature — `.claude/spec/<feature-slug>.md`.
|
||||||
|
3. If nothing is found, ask the user where the spec is.
|
||||||
|
If they say there isn't one, the **Spec** sub-agent will skip and report "no spec available".
|
||||||
|
|
||||||
|
### 3. Identify the standards sources
|
||||||
|
|
||||||
|
Anything in the repo that documents how code should be written, such as `CODING_STANDARDS.md` or `CONTRIBUTING.md`.
|
||||||
|
|
||||||
|
On top of whatever the repo documents, the Standards axis always carries the **smell baseline** below — a fixed set of Fowler code smells (_Refactoring_, ch.3) that applies even when a repo documents nothing.
|
||||||
|
Two rules bind it:
|
||||||
|
|
||||||
|
- **The repo overrides.**
|
||||||
|
A documented repo standard always wins; where it endorses something the baseline would flag, suppress the smell.
|
||||||
|
- **Always a judgement call.**
|
||||||
|
Each smell is a labelled heuristic ("possible Feature Envy"), never a hard violation — and, like any standard here, skip anything tooling already enforces.
|
||||||
|
|
||||||
|
Each smell reads *what it is* → *how to fix*; match it against the diff:
|
||||||
|
|
||||||
|
- **Mysterious Name** — a function, variable, or type whose name doesn't reveal what it does or holds.
|
||||||
|
→ rename it; if no honest name comes, the design's murky.
|
||||||
|
- **Duplicated Code** — the same logic shape appears in more than one hunk or file in the change.
|
||||||
|
→ extract the shared shape, call it from both.
|
||||||
|
- **Feature Envy** — a method that reaches into another object's data more than its own.
|
||||||
|
→ move the method onto the data it envies.
|
||||||
|
- **Data Clumps** — the same few fields or params keep travelling together (a type wanting to be born).
|
||||||
|
→ bundle them into one type, pass that.
|
||||||
|
- **Primitive Obsession** — a primitive or string standing in for a domain concept that deserves its own type.
|
||||||
|
→ give the concept its own small type.
|
||||||
|
- **Repeated Switches** — the same `switch`/`if`-cascade on the same type recurs across the change.
|
||||||
|
→ replace with polymorphism, or one map both sites share.
|
||||||
|
- **Shotgun Surgery** — one logical change forces scattered edits across many files in the diff.
|
||||||
|
→ gather what changes together into one module.
|
||||||
|
- **Divergent Change** — one file or module is edited for several unrelated reasons.
|
||||||
|
→ split so each module changes for one reason.
|
||||||
|
- **Speculative Generality** — abstraction, parameters, or hooks added for needs the spec doesn't have.
|
||||||
|
→ delete it; inline back until a real need shows.
|
||||||
|
- **Message Chains** — long `a.b().c().d()` navigation the caller shouldn't depend on.
|
||||||
|
→ hide the walk behind one method on the first object.
|
||||||
|
- **Middle Man** — a class or function that mostly just delegates onward.
|
||||||
|
→ cut it, call the real target direct.
|
||||||
|
- **Refused Bequest** — a subclass or implementer that ignores or overrides most of what it inherits.
|
||||||
|
→ drop the inheritance, use composition.
|
||||||
|
|
||||||
|
### 4. Risk rubric
|
||||||
|
|
||||||
|
The Risk axis judges the diff alone — no repo-doc lookup, no input from the Standards or Spec sub-agents.
|
||||||
|
It always runs; it only needs the diff from step 1.
|
||||||
|
|
||||||
|
Rate each of these six factors **Low / Medium / High**, then take the single highest-rated factor as the overall rating (worst-factor-wins):
|
||||||
|
|
||||||
|
- **Blast radius** — isolated change vs. ripples across many files, modules, or callers.
|
||||||
|
- **Reversibility** — trivial rollback vs. hard to undo (migrations, deletions, published API/schema changes).
|
||||||
|
- **Test coverage** — covered by tests in/around the diff vs. untested.
|
||||||
|
- **Sensitive domain** — touches auth, security, payments, permissions, concurrency, or data migrations.
|
||||||
|
- **Size & complexity** — large diff or tangled control flow vs. small/simple.
|
||||||
|
- **Runtime criticality** — hot path/production-critical vs. internal or dev-only tooling.
|
||||||
|
|
||||||
|
### 5. Spawn all three sub-agents in parallel
|
||||||
|
|
||||||
|
Send a single message with three `Agent` tool calls.
|
||||||
|
Use the `general-purpose` subagent for all three.
|
||||||
|
|
||||||
|
**Risk sub-agent prompt** — include:
|
||||||
|
|
||||||
|
- The full diff (output of `git diff HEAD`).
|
||||||
|
- The six risk factors from step 4, pasted in full.
|
||||||
|
- The brief: "Rate each of the six factors Low/Medium/High with a one-clause reason, then give the overall rating as the highest of the six.
|
||||||
|
Report the overall rating first, then the six factor lines.
|
||||||
|
Under 200 words."
|
||||||
|
|
||||||
|
**Standards sub-agent prompt** — include:
|
||||||
|
|
||||||
|
- The full diff (output of `git diff HEAD`).
|
||||||
|
- The list of standards-source files you found in step 3, **plus the smell baseline from step 3** pasted in full — the sub-agent has no other access to it.
|
||||||
|
- The brief: "Report — per file/hunk where relevant — (a) every place the diff violates a documented standard: cite the standard (file + the rule); and (b) any baseline smell you spot: name it and quote the hunk.
|
||||||
|
Distinguish hard violations from judgement calls — documented-standard breaches can be hard, but baseline smells are always judgement calls, and a documented repo standard overrides the baseline.
|
||||||
|
Skip anything tooling enforces.
|
||||||
|
Under 400 words."
|
||||||
|
|
||||||
|
**Spec sub-agent prompt** — include:
|
||||||
|
|
||||||
|
- The full diff (output of `git diff HEAD`).
|
||||||
|
- The path or fetched contents of the spec.
|
||||||
|
- The brief: "Report: (a) requirements the spec asked for that are missing or partial; (b) behaviour in the diff that wasn't asked for (scope creep); (c) requirements that look implemented but where the implementation looks wrong.
|
||||||
|
Quote the spec line for each finding.
|
||||||
|
Under 400 words."
|
||||||
|
|
||||||
|
If the spec is missing, skip the Spec sub-agent and note this in the final report.
|
||||||
|
|
||||||
|
### 6. Aggregate
|
||||||
|
|
||||||
|
Present the Risk report first, under a `## Risk` heading, with the overall rating bolded on its own line followed by the six factor lines:
|
||||||
|
|
||||||
|
```
|
||||||
|
## Risk
|
||||||
|
**Overall: HIGH**
|
||||||
|
- Blast radius: ...
|
||||||
|
- Reversibility: ...
|
||||||
|
- Test coverage: ...
|
||||||
|
- Sensitive domain: ...
|
||||||
|
- Size & complexity: ...
|
||||||
|
- Runtime criticality: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Then present the Standards and Spec reports under `## Standards` and `## Spec` headings, verbatim or lightly cleaned.
|
||||||
|
Do **not** merge or rerank findings — the axes are deliberately separate (see _Why Standards and Spec stay separate_).
|
||||||
|
|
||||||
|
End with a one-line summary: total findings per axis (Standards/Spec only), and the worst issue _within each axis_ (if any).
|
||||||
|
Don't pick a single winner across axes — that's the reranking the separation exists to prevent.
|
||||||
|
The risk rating isn't repeated here; it already leads the report.
|
||||||
|
|
||||||
|
## Why Standards and Spec stay separate
|
||||||
|
|
||||||
|
A change can pass one axis and fail the other:
|
||||||
|
|
||||||
|
- Code that follows every standard but implements the wrong thing → **Standards pass, Spec fail.**
|
||||||
|
- Code that does exactly what the PRD or task asked but breaks the project's conventions → **Spec pass, Standards fail.**
|
||||||
|
|
||||||
|
Reporting them separately stops one axis from masking the other.
|
||||||
53
modules/agents/claude-code/skills/setup-skills/LOCKFILE.md
Normal file
53
modules/agents/claude-code/skills/setup-skills/LOCKFILE.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Skills Lockfile
|
||||||
|
|
||||||
|
`.claude/skills-lock.yaml`, at the root of a project, tracks which library
|
||||||
|
skills (from `~/.claude/skills/library/`) have been copied into that
|
||||||
|
project's `.claude/skills/`, so [`setup-skills`](SKILL.md),
|
||||||
|
[`update-skills`](../update-skills/SKILL.md), and
|
||||||
|
[`remove-skills`](../remove-skills/SKILL.md) all agree on what's installed
|
||||||
|
without re-deriving it from the filesystem.
|
||||||
|
|
||||||
|
## Schema
|
||||||
|
|
||||||
|
A YAML list of entries, one per installed skill:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: nbdev
|
||||||
|
hash: 3f2a9b8c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a
|
||||||
|
- name: terraform-conventions
|
||||||
|
hash: 9c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a3f2a9b
|
||||||
|
```
|
||||||
|
|
||||||
|
- `name` — matches both the skill's directory name in the library
|
||||||
|
(`skills/library/<name>`) and its copied directory name in the project
|
||||||
|
(`.claude/skills/<name>`).
|
||||||
|
- `hash` — the output of `hash-dir.sh` run against that one skill's
|
||||||
|
directory contents, recorded at the moment it was last copied or
|
||||||
|
confirmed up to date. Never a hash of anything else — not the whole
|
||||||
|
project, not the whole library, just that one skill's own directory
|
||||||
|
tree.
|
||||||
|
|
||||||
|
## What a mismatch means
|
||||||
|
|
||||||
|
To classify a skill's state, compare three values: the lockfile's stored
|
||||||
|
`hash`, `hash-dir.sh` on the project's current copy
|
||||||
|
(`.claude/skills/<name>`), and `hash-dir.sh` on the library's current
|
||||||
|
source (`~/.claude/skills/library/<name>`).
|
||||||
|
|
||||||
|
| stored vs. project copy | stored vs. library source | meaning |
|
||||||
|
|--------------------------|----------------------------|--------------------------------------|
|
||||||
|
| match | match | nothing to do |
|
||||||
|
| match | differs | library moved on — safe to update |
|
||||||
|
| differs | match | project customized on purpose — leave it |
|
||||||
|
| differs | differs | conflict — report, don't touch |
|
||||||
|
|
||||||
|
## Writing to the lockfile
|
||||||
|
|
||||||
|
- Adding a skill: append a new `{name, hash}` entry.
|
||||||
|
- Applying a safe update: overwrite that entry's `hash` in place with the
|
||||||
|
library's current hash.
|
||||||
|
- Removing a skill: delete its entry entirely.
|
||||||
|
|
||||||
|
Never reorder or restructure existing entries beyond what an add, update,
|
||||||
|
or remove requires — this file is meant to diff cleanly in a project's
|
||||||
|
git history.
|
||||||
53
modules/agents/claude-code/skills/setup-skills/SKILL.md
Normal file
53
modules/agents/claude-code/skills/setup-skills/SKILL.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
name: setup-skills
|
||||||
|
description: Add relevant skills from the shared skills library to the current project.
|
||||||
|
disable-model-invocation: true
|
||||||
|
---
|
||||||
|
|
||||||
|
Adds opt-in, project-specific skills from `~/.claude/skills/library/` into
|
||||||
|
the current project's `.claude/skills/`, tracked in
|
||||||
|
`.claude/skills-lock.yaml` (see [LOCKFILE.md](LOCKFILE.md) for its schema).
|
||||||
|
Only ever adds — checking already-installed skills for updates is
|
||||||
|
[`update-skills`](../update-skills/SKILL.md)'s job, not this one's.
|
||||||
|
|
||||||
|
The library is a tree of read-only symlinks into the Nix store, so every
|
||||||
|
copy out of it must dereference (`cp -rL`) and then restore write
|
||||||
|
permission (`chmod -R u+w`). A plain `cp -r` copies the symlinks
|
||||||
|
themselves, putting store paths into the project that break on any other
|
||||||
|
machine.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Read `.claude/skills-lock.yaml` in the current project, if it exists.
|
||||||
|
Note every skill name already listed — these are already installed and
|
||||||
|
must not be re-proposed.
|
||||||
|
|
||||||
|
2. List every skill under `~/.claude/skills/library/*/SKILL.md` and read
|
||||||
|
each one's `name` and `description`.
|
||||||
|
|
||||||
|
3. Inspect the current project (file tree, manifests like
|
||||||
|
`pyproject.toml`/`package.json`, file extensions present, etc.) and
|
||||||
|
judge which library skills — excluding ones already installed — seem
|
||||||
|
relevant, the same way you'd reason about any unfamiliar codebase.
|
||||||
|
Propose that shortlist to the user with your reasoning, one line per
|
||||||
|
skill. If the user asks to see the full catalog instead, list every
|
||||||
|
library skill (minus already-installed ones) with its description.
|
||||||
|
|
||||||
|
4. Let the user confirm, adjust, or pick freely from the full list.
|
||||||
|
|
||||||
|
5. For each confirmed skill:
|
||||||
|
- If `.claude/skills/<name>/` already exists in the project and is
|
||||||
|
*not* in the lockfile, skip it and tell the user why (a same-named
|
||||||
|
skill already lives there and isn't tracked — remove or rename it
|
||||||
|
first if they want the library version).
|
||||||
|
- Otherwise, copy `~/.claude/skills/library/<name>/` to
|
||||||
|
`.claude/skills/<name>/` in the project with
|
||||||
|
`cp -rL` followed by `chmod -R u+w`, run
|
||||||
|
`~/.claude/skills/setup-skills/hash-dir.sh .claude/skills/<name>`,
|
||||||
|
and append `{name, hash: <output>}` to `.claude/skills-lock.yaml`
|
||||||
|
(create the file, an empty YAML list, if it doesn't exist yet).
|
||||||
|
|
||||||
|
6. Report what was added and what was skipped, and why.
|
||||||
|
|
||||||
|
Done when every confirmed skill is either copied and recorded in the
|
||||||
|
lockfile, or explicitly skipped with a stated reason.
|
||||||
23
modules/agents/claude-code/skills/setup-skills/hash-dir.sh
Executable file
23
modules/agents/claude-code/skills/setup-skills/hash-dir.sh
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Deterministic recursive hash of a directory's file contents.
|
||||||
|
#
|
||||||
|
# Hashes relative paths, not absolute ones, so two directories with
|
||||||
|
# identical contents hash identically regardless of where they live on
|
||||||
|
# disk (needed to compare a project's copied skill against the library
|
||||||
|
# source it was copied from).
|
||||||
|
#
|
||||||
|
# Usage: hash-dir.sh <directory>
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ $# -ne 1 ]; then
|
||||||
|
echo "Usage: hash-dir.sh <directory>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
dir="$1"
|
||||||
|
if [ ! -d "$dir" ]; then
|
||||||
|
echo "Not a directory: $dir" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
(cd "$dir" && find . -type f -print0 | sort -z | xargs -0 -r sha256sum) | sha256sum | awk '{print $1}'
|
||||||
@@ -28,7 +28,9 @@ This produces **crap tests**:
|
|||||||
- Tests become insensitive to real changes - they pass when behavior breaks, fail when behavior is fine
|
- Tests become insensitive to real changes - they pass when behavior breaks, fail when behavior is fine
|
||||||
- You outrun your headlights, committing to test structure before understanding the implementation
|
- You outrun your headlights, committing to test structure before understanding the implementation
|
||||||
|
|
||||||
**Correct approach**: Vertical slices via tracer bullets. One test → one implementation → repeat. Each test responds to what you learned from the previous cycle. Because you just wrote the code, you know exactly what behavior matters and how to verify it.
|
**Correct approach**: Vertical slices via tracer bullets. One test → one implementation → repeat. Each test responds to what you learned from the previous cycle.
|
||||||
|
|
||||||
|
The test-writer sub-agent (below) is handed **one behavior at a time** and never sees the behavior backlog, so it can't bulk-write the suite.
|
||||||
|
|
||||||
```
|
```
|
||||||
WRONG (horizontal):
|
WRONG (horizontal):
|
||||||
@@ -42,6 +44,25 @@ RIGHT (vertical):
|
|||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
|
||||||
|
Every test is written by a **test-writer sub-agent**. The main agent writes every line of implementation, and never writes or edits a test.
|
||||||
|
|
||||||
|
The sub-agent must not read the implementation source of the module under test — that is what keeps its tests from asserting _how_ instead of _what_. It works from the public interface alone.
|
||||||
|
|
||||||
|
Use one `general-purpose` sub-agent for the whole task: spawn it at the first RED, then continue it with `SendMessage` for each subsequent RED, so it keeps the test file and conventions it established. Cold-spawn a replacement only if its ID is lost.
|
||||||
|
|
||||||
|
### Test-writer sub-agent prompt — include:
|
||||||
|
|
||||||
|
- **One behavior**, quoted verbatim from the acceptance criterion or the agreed behavior list. Never the task file, never the rest of the list.
|
||||||
|
- The **public interface** under test — signatures only.
|
||||||
|
- The existing test file(s) for the module, and the project's test conventions (fixtures, helpers, runner invocation).
|
||||||
|
- [tests.md](tests.md) and [mocking.md](mocking.md).
|
||||||
|
- The **independent source of truth for the expected value** — the spec excerpt, worked example, or known-good literal. Without it the sub-agent recomputes the expected value the way the code would, and the test is tautological.
|
||||||
|
- `.claude/CONTEXT.md` (if it exists) and any ADRs in the area, so test names and interface vocabulary match the project's domain language.
|
||||||
|
- The test-side checklist from [Checklist Per Cycle](#checklist-per-cycle), pasted in full — the sub-agent has no other access to it.
|
||||||
|
- The brief: "Write ONE test for this behavior. Do not read the implementation source of the module under test. Write it to the test file, run it, and confirm it fails with a genuine assertion failure — not an import, syntax, or collection error, which prove nothing. Report the test's name and the exact failure message you saw."
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
### 1. Planning
|
### 1. Planning
|
||||||
@@ -63,13 +84,15 @@ Ask: "What should the public interface look like? Which behaviors are most impor
|
|||||||
|
|
||||||
**You can't test everything.** Confirm with the user exactly which behaviors matter most. Focus testing effort on critical paths and complex logic, not every possible edge case.
|
**You can't test everything.** Confirm with the user exactly which behaviors matter most. Focus testing effort on critical paths and complex logic, not every possible edge case.
|
||||||
|
|
||||||
|
Planning stays with the main agent on both paths — exploration, interface, and the order behaviors are tested in. The sub-agent receives behaviors one at a time; it never chooses what to test next.
|
||||||
|
|
||||||
### 2. Tracer Bullet
|
### 2. Tracer Bullet
|
||||||
|
|
||||||
Write ONE test that confirms ONE thing about the system:
|
ONE test that confirms ONE thing about the system:
|
||||||
|
|
||||||
```
|
```
|
||||||
RED: Write test for first behavior → test fails
|
RED: Spawn the test-writer sub-agent with the first behavior → it writes the test, runs it, reports a genuine failure
|
||||||
GREEN: Write minimal code to pass → test passes
|
GREEN: Main agent writes minimal code to pass → test passes
|
||||||
```
|
```
|
||||||
|
|
||||||
This is your tracer bullet - proves the path works end-to-end.
|
This is your tracer bullet - proves the path works end-to-end.
|
||||||
@@ -79,8 +102,8 @@ This is your tracer bullet - proves the path works end-to-end.
|
|||||||
For each remaining behavior:
|
For each remaining behavior:
|
||||||
|
|
||||||
```
|
```
|
||||||
RED: Write next test → fails
|
RED: SendMessage the same sub-agent the next behavior → it writes the test, runs it, reports a genuine failure
|
||||||
GREEN: Minimal code to pass → passes
|
GREEN: Main agent writes minimal code to pass → passes
|
||||||
```
|
```
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
@@ -90,6 +113,13 @@ Rules:
|
|||||||
- Don't anticipate future tests
|
- Don't anticipate future tests
|
||||||
- Keep tests focused on observable behavior
|
- Keep tests focused on observable behavior
|
||||||
|
|
||||||
|
### When a test looks wrong
|
||||||
|
|
||||||
|
The main agent never edits a sub-agent-authored test — not to fix an import, not to "simplify" an assertion, not to reach GREEN.
|
||||||
|
|
||||||
|
- **Mechanical defect** — bad import path, a fixture or helper that doesn't exist, doesn't parse. Send the error output back to the sub-agent and let it fix its own test.
|
||||||
|
- **Semantic disagreement** — you believe the expected value or the asserted behavior is wrong. Stop and ask the user. Do not resolve it yourself; this disagreement is the signal the sub-agent exists to surface, and half the time it's the code that's wrong.
|
||||||
|
|
||||||
### 4. Refactor
|
### 4. Refactor
|
||||||
|
|
||||||
After all tests pass, look for [refactor candidates](refactoring.md):
|
After all tests pass, look for [refactor candidates](refactoring.md):
|
||||||
@@ -102,13 +132,22 @@ After all tests pass, look for [refactor candidates](refactoring.md):
|
|||||||
|
|
||||||
**Never refactor while RED.** Get to GREEN first.
|
**Never refactor while RED.** Get to GREEN first.
|
||||||
|
|
||||||
|
A test that breaks during refactor means the refactor broke behavior — fix the code. The one exception is a public interface change you made deliberately (a module deepened, a signature moved, as agreed in the plan): send the interface change to the sub-agent and let it update its own tests. There is no case where the main agent edits the test itself.
|
||||||
|
|
||||||
## Checklist Per Cycle
|
## Checklist Per Cycle
|
||||||
|
|
||||||
|
Test-writer sub-agent, per test — paste into its prompt:
|
||||||
|
|
||||||
```
|
```
|
||||||
[ ] Test describes behavior, not implementation
|
[ ] Test describes behavior, not implementation
|
||||||
[ ] Test uses public interface only
|
[ ] Test uses public interface only
|
||||||
[ ] Test would survive internal refactor
|
[ ] Test would survive internal refactor
|
||||||
[ ] Expected values are independent literals, not recomputed from the code
|
[ ] Expected values are independent literals, not recomputed from the code
|
||||||
|
```
|
||||||
|
|
||||||
|
Main agent, per GREEN:
|
||||||
|
|
||||||
|
```
|
||||||
[ ] Code is minimal for this test
|
[ ] Code is minimal for this test
|
||||||
[ ] No speculative features added
|
[ ] No speculative features added
|
||||||
```
|
```
|
||||||
@@ -34,7 +34,7 @@ Break the plan into **tracer bullet** tasks — vertical slices, not horizontal
|
|||||||
|
|
||||||
### 4. Quiz the user
|
### 4. Quiz the user
|
||||||
|
|
||||||
Number slices with a single sequence shared across every file already in `.claude/tasks/`: scan for the highest existing `NNNN` (four-digit, zero-padded lowercase hex, `0000`-`ffff`) and increment from there. Never restart the sequence per feature and never reuse a number.
|
Number slices with a single sequence shared across every file already in `.claude/tasks/`: scan for the highest existing `NNNN` (four-digit, zero-padded decimal, `0000`-`9999`) and increment from there. Never restart the sequence per feature and never reuse a number.
|
||||||
|
|
||||||
Present the proposed breakdown as a numbered list. For each slice, show:
|
Present the proposed breakdown as a numbered list. For each slice, show:
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ A concise description of this vertical slice. Describe the end-to-end behavior,
|
|||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- **`spec`**: the feature-slug this task was written from. Omit the field entirely if there's no spec.
|
- **`spec`**: the feature-slug this task was written from. Omit the field entirely if there's no spec.
|
||||||
- **`blocked-by`**: which other task(s) must complete before this one can start. Omit the field entirely if there are none. Each value is the blocking task's full `<NNNN>-<slice-slug>` filename stem, not just its slug. A single blocker is a bare string (`blocked-by: 000a-add-schema`); more than one is a YAML list (`blocked-by: [000a-add-schema, 000b-wire-api]`). Once written, keep the field even after the referenced task is completed — it's a permanent record of the dependency, not a "still blocked" flag.
|
- **`blocked-by`**: which other task(s) must complete before this one can start. Omit the field entirely if there are none. Each value is the blocking task's full `<NNNN>-<slice-slug>` filename stem, not just its slug. A single blocker is a bare string (`blocked-by: 0010-add-schema`); more than one is a YAML list (`blocked-by: [0010-add-schema, 0011-wire-api]`). Once written, keep the field even after the referenced task is completed — it's a permanent record of the dependency, not a "still blocked" flag.
|
||||||
- **Don't include specific file paths or code snippets** in "What to build" — they go stale fast. Exception: if a prototype produced a snippet that encodes a decision more precisely than prose can (state machine, reducer, schema, type shape), inline it here and note briefly that it came from a prototype. Trim to the decision-rich parts — not a working demo, just the important bits.
|
- **Don't include specific file paths or code snippets** in "What to build" — they go stale fast. Exception: if a prototype produced a snippet that encodes a decision more precisely than prose can (state machine, reducer, schema, type shape), inline it here and note briefly that it came from a prototype. Trim to the decision-rich parts — not a working demo, just the important bits.
|
||||||
- A task is done when every criterion in "Acceptance criteria" is resolved: mark `[x]` as satisfied, or `[-]` if deliberately dropped (`/implement` records the reason in the task's Implementation Notes) — track completion here, not anywhere else.
|
- A task is done when every criterion in "Acceptance criteria" is resolved: mark `[x]` as satisfied, or `[-]` if deliberately dropped (`/implement` records the reason in the task's Implementation Notes) — track completion here, not anywhere else.
|
||||||
- A slice becomes pickable once every task named in `blocked-by` is done (all of its acceptance criteria resolved) — check the referenced tasks' state, not just whether the field is present. The file's number is an identifier and a rough ordering hint, not a strict gate — sibling slices with no blockers can be worked in parallel.
|
- A slice becomes pickable once every task named in `blocked-by` is done (all of its acceptance criteria resolved) — check the referenced tasks' state, not just whether the field is present. The file's number is an identifier and a rough ordering hint, not a strict gate — sibling slices with no blockers can be worked in parallel.
|
||||||
55
modules/agents/claude-code/skills/update-skills/SKILL.md
Normal file
55
modules/agents/claude-code/skills/update-skills/SKILL.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
name: update-skills
|
||||||
|
description: Check the current project's installed library skills for upstream changes and apply the safe ones.
|
||||||
|
disable-model-invocation: true
|
||||||
|
---
|
||||||
|
|
||||||
|
Compares every skill listed in the current project's
|
||||||
|
`.claude/skills-lock.yaml` (see [LOCKFILE.md](../setup-skills/LOCKFILE.md)
|
||||||
|
for its schema) against both the project's own copy and the current
|
||||||
|
library source, and decides what to do about each one. Never installs a
|
||||||
|
skill that isn't already there — that's
|
||||||
|
[`setup-skills`](../setup-skills/SKILL.md)'s job.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Read `.claude/skills-lock.yaml`. If it doesn't exist or is empty, tell
|
||||||
|
the user there's nothing to check and stop.
|
||||||
|
|
||||||
|
2. For each `{name, hash}` entry, compute:
|
||||||
|
- `project_hash`: `~/.claude/skills/setup-skills/hash-dir.sh .claude/skills/<name>`
|
||||||
|
- `library_hash`: `~/.claude/skills/setup-skills/hash-dir.sh ~/.claude/skills/library/<name>`
|
||||||
|
|
||||||
|
If either path is missing entirely, report that anomaly for this skill
|
||||||
|
(don't try to classify it) and move on to the next entry.
|
||||||
|
|
||||||
|
3. Classify each entry against the table in
|
||||||
|
[LOCKFILE.md](../setup-skills/LOCKFILE.md#what-a-mismatch-means),
|
||||||
|
using `project_hash` in place of "project copy" and `library_hash` in
|
||||||
|
place of "library source". The two outcomes that need action below are
|
||||||
|
**safe update** (stored matches project, differs from library) and
|
||||||
|
**conflict** (stored differs from both). "Locally customized" needs no
|
||||||
|
message beyond the summary.
|
||||||
|
|
||||||
|
4. If there are any safe updates, list them by name and ask for one
|
||||||
|
confirmation to apply all of them — unless the user's invocation
|
||||||
|
already included an explicit go-ahead argument (e.g. `-y`, `yes`), in
|
||||||
|
which case apply them without asking. Applying means: delete
|
||||||
|
`.claude/skills/<name>/` entirely and copy
|
||||||
|
`~/.claude/skills/library/<name>/` in its place with `cp -rL` followed
|
||||||
|
by `chmod -R u+w` (the library is read-only symlinks into the Nix store;
|
||||||
|
a plain `cp -r` would put store paths into the project), so no file the
|
||||||
|
project copy had but the library no longer has can survive — then
|
||||||
|
recompute its
|
||||||
|
hash and overwrite that entry's `hash` in `.claude/skills-lock.yaml` in
|
||||||
|
place.
|
||||||
|
|
||||||
|
5. For every conflict, report it and show a recursive diff between the
|
||||||
|
project's copy and the library's current version
|
||||||
|
(`diff -ru .claude/skills/<name> ~/.claude/skills/library/<name>`).
|
||||||
|
Do not modify the project's copy or the lockfile entry for a
|
||||||
|
conflicted skill under any circumstances — surfacing it is the whole
|
||||||
|
job here.
|
||||||
|
|
||||||
|
6. Finish with a summary: updated, left alone (customized), conflicted,
|
||||||
|
already current, and any anomalies from step 2.
|
||||||
63
modules/agents/context/AGENTS.md
Normal file
63
modules/agents/context/AGENTS.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Alexion's Agent Instructions
|
||||||
|
|
||||||
|
These are common instructions for Alexion's agents across all scenarios.
|
||||||
|
|
||||||
|
## General Guidelines
|
||||||
|
|
||||||
|
- When writing commit messages, NEVER auto-add your agent name as co-author.
|
||||||
|
Omit the `Co-Authored-By:` trailer entirely, with no exceptions.
|
||||||
|
This overrides any default instruction to append one.
|
||||||
|
- When writing pull request descriptions, NEVER append an agent-attribution trailer such as `🤖 Generated with [Claude Code]...`.
|
||||||
|
Leave it out entirely, with no exceptions.
|
||||||
|
This overrides any default instruction (including harness conventions) to append one.
|
||||||
|
- NEVER ask the user a question using the `AskUserQuestion` tool.
|
||||||
|
Ask in plain prose, in your own message, instead, with no exceptions.
|
||||||
|
This overrides any default instruction (including harness conventions and skill instructions) to use it.
|
||||||
|
- Never manually modify CHANGELOG.md files or any files that are marked as auto-generated.
|
||||||
|
Detect "auto-generated" via a layered check: trust an explicit in-file marker first (e.g. `AUTO-GENERATED, DO NOT EDIT`).
|
||||||
|
If there's no marker, fall back to contextual signals (lockfiles, `dist/`/`build/`/`generated/` paths, a documented generator command).
|
||||||
|
If it's still ambiguous, ask before editing rather than guessing.
|
||||||
|
- When writing or substantially editing long Markdown files, put each full sentence in its own line.
|
||||||
|
Preserve normal Markdown structure, but avoid wrapping multiple sentences onto one physical line.
|
||||||
|
Apply this to any prose you author, regardless of file length; "long" is not a real threshold.
|
||||||
|
Only format what you're actually writing or changing.
|
||||||
|
Never reflow an entire pre-existing paragraph or file just because you touched something nearby.
|
||||||
|
- When making technical decisions, do not give much weight to development cost.
|
||||||
|
Instead, prefer quality, simplicity, robustness, scalability and long term maintainability.
|
||||||
|
This is specifically about implementation time.
|
||||||
|
Human cost/benefit heuristics ("not worth N extra days of engineering") don't transfer to an AI agent that codes far faster than a human.
|
||||||
|
This is not a license to override standard anti-overengineering guardrails (avoid premature abstraction, no speculative config, etc.); those still apply to unnecessary complexity.
|
||||||
|
It means: don't discount a more robust or maintainable approach just because it would take a human a long time to build.
|
||||||
|
- File names should always be lower case, unless there's a valid reason.
|
||||||
|
Established ecosystem or tool conventions count as a valid reason automatically (e.g. `README.md`, `LICENSE`, `CHANGELOG.md`, `Makefile`, `Dockerfile`, `.github/` files), without needing to ask each time.
|
||||||
|
- Do not end a response by promising or implying continuation unless the continuation is present in that same response.
|
||||||
|
If a workflow should continue, perform the next step before ending the turn.
|
||||||
|
If the workflow is paused, say that plainly instead of using a dangling transition like "continuing" or "next".
|
||||||
|
- When you discover that a belief you held about an objective fact or convention of the current project was wrong, write it down so it isn't relearned next time.
|
||||||
|
This applies whether the user corrected you or you caught the mistake yourself, and only to things that are true regardless of who is operating the project (a wrong build command, a wrong file path, a convention you guessed at instead of checking) — not personal working-style preferences or one-off task details.
|
||||||
|
Record it in that project's own AGENTS.md, not this global file, under a dedicated `## Gotchas` section (create the section if the file doesn't have one yet).
|
||||||
|
If the project has nested AGENTS.md files, use the one nearest to where the mistake occurred, falling back to the project's top-level AGENTS.md.
|
||||||
|
Append to an existing AGENTS.md immediately, without asking; if no AGENTS.md exists yet for the project, ask before creating one.
|
||||||
|
Briefly mention the edit in your response rather than making it silently.
|
||||||
|
If an existing entry is later found to be wrong or stale, correct or remove it the same way.
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
- Write comments only where they earn their place, and keep them concise.
|
||||||
|
Assume the reader can read code: comment the "why", not the "what", and explain "what" only when it is genuinely non-obvious.
|
||||||
|
A comment must be self-contained to its file — accurate to a reader looking at that file alone.
|
||||||
|
Do not write about history ("used to be X", "now moved here") or future state, about how a value is consumed elsewhere, or to justify the choice against alternatives; state the positive reason a thing exists, keeping any real stakes as a present-tense consequence.
|
||||||
|
The only permitted cross-file mention is a bare pointer explaining why something is *absent* here (e.g. a value another tool derives, which this file therefore does not declare), never narrating what the other file or tool does.
|
||||||
|
Do not use a project's domain-model or ubiquitous-language capitalized terms as glossary references; describe things in plain language, using ordinary lowercase nouns.
|
||||||
|
Never reference agent-facing state (anything under `.agents/`, `.claude/`, `AGENTS.md`, or `CLAUDE.md`).
|
||||||
|
A file-top header is one concise purpose line, added only where the filename or path does not already say it — never a feature inventory of the code below.
|
||||||
|
For a placeholder, say so plainly plus any actionable present-tense directive ("Placeholder: regenerate with <tool> on the target machine"), never "placeholder for <missing feature>".
|
||||||
|
User-facing documentation strings (an option's `description`, a generated help string) are documentation rather than comments, so they may describe behaviour more fully — but the self-contained rule and the bans on glossary terms and agent-state references still apply.
|
||||||
|
- Start each sentence of a comment on its own line, as with Markdown prose.
|
||||||
|
A sentence needing more than one line is first a prompt to ask whether it should be two sentences.
|
||||||
|
Only when it genuinely cannot be split does it wrap, and then it wraps normally at the right margin.
|
||||||
|
Never break a line early at a comma or clause boundary to make it read as a unit.
|
||||||
|
Never use a semicolon, in a comment or in authored prose.
|
||||||
|
Recast as two sentences instead.
|
||||||
|
Only reformat comments you are actually writing or changing.
|
||||||
|
|
||||||
21
modules/agents/context/context.nix
Normal file
21
modules/agents/context/context.nix
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
# Shared global instructions for agent harnesses.
|
||||||
|
let
|
||||||
|
user = config.user.name;
|
||||||
|
context = builtins.readFile ./AGENTS.md;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
config = lib.mkMerge [
|
||||||
|
(lib.mkIf config.modules.agents.claude-code.enable {
|
||||||
|
home-manager.users.${user}.programs.claude-code.context = context;
|
||||||
|
})
|
||||||
|
|
||||||
|
(lib.mkIf config.modules.agents.pi.enable {
|
||||||
|
home-manager.users.${user}.programs.pi-coding-agent.context = context;
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}
|
||||||
42
modules/agents/herdr.nix
Normal file
42
modules/agents/herdr.nix
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
# Herdr, a terminal multiplexer for coding agents.
|
||||||
|
let
|
||||||
|
cfg = config.modules.agents.herdr;
|
||||||
|
user = config.user.name;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.modules.agents.herdr.enable = lib.mkEnableOption "Herdr, a terminal multiplexer for coding agents";
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
home-manager.users.${user} = {
|
||||||
|
home.packages = [ pkgs.herdr ];
|
||||||
|
|
||||||
|
xdg.configFile."herdr/config.toml".text = ''
|
||||||
|
[keys]
|
||||||
|
prefix = "ctrl+space"
|
||||||
|
detach = "prefix+d"
|
||||||
|
reload_config = "prefix+r"
|
||||||
|
new_workspace = "prefix+c"
|
||||||
|
new_tab = "prefix+shift+c"
|
||||||
|
rename_workspace = "prefix+comma"
|
||||||
|
rename_tab = "prefix+<"
|
||||||
|
split_vertical = "prefix+backslash"
|
||||||
|
split_horizontal = "prefix+minus"
|
||||||
|
switch_workspace = "prefix+1..9"
|
||||||
|
switch_tab = "prefix+shift+1..9"
|
||||||
|
focus_pane_left = "prefix+h"
|
||||||
|
focus_pane_down = "prefix+j"
|
||||||
|
focus_pane_up = "prefix+k"
|
||||||
|
focus_pane_right = "prefix+l"
|
||||||
|
|
||||||
|
[ui]
|
||||||
|
prompt_new_tab_name = false
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
254
modules/agents/pi/extensions/compact-status.ts
Normal file
254
modules/agents/pi/extensions/compact-status.ts
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { basename, join } from "node:path";
|
||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
||||||
|
|
||||||
|
type QuotaState =
|
||||||
|
| { status: "idle" | "loading" }
|
||||||
|
| { status: "ok"; detail: string; refreshedAt: number; weeklyRemaining?: number; shortRemaining?: number }
|
||||||
|
| { status: "missing" | "error"; detail: string; refreshedAt?: number };
|
||||||
|
|
||||||
|
const CODEX_USAGE_ENDPOINTS = [
|
||||||
|
"https://chatgpt.com/backend-api/wham/usage",
|
||||||
|
"https://chatgpt.com/backend-api/codex/usage",
|
||||||
|
];
|
||||||
|
const QUOTA_REFRESH_MS = 5 * 60 * 1000;
|
||||||
|
const REQUEST_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
let quotaState: QuotaState = { status: "idle" };
|
||||||
|
let quotaRefreshPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
function shortCwd(cwd: string): string {
|
||||||
|
const home = process.env.HOME;
|
||||||
|
if (home && cwd.startsWith(`${home}/`)) return `~/${basename(cwd)}`;
|
||||||
|
return basename(cwd) || cwd;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitBranch(cwd: string): string | null {
|
||||||
|
try {
|
||||||
|
const out = execFileSync("git", ["--no-optional-locks", "symbolic-ref", "--quiet", "--short", "HEAD"], {
|
||||||
|
cwd,
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
}).trim();
|
||||||
|
return out || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function authPath(): string {
|
||||||
|
return join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "auth.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCodexCredentials(): { access: string; accountId?: string } | null {
|
||||||
|
const file = authPath();
|
||||||
|
if (!existsSync(file)) return null;
|
||||||
|
try {
|
||||||
|
const auth = JSON.parse(readFileSync(file, "utf8"));
|
||||||
|
const credential = auth?.["openai-codex"];
|
||||||
|
if (credential?.type !== "oauth" || typeof credential.access !== "string") return null;
|
||||||
|
if (typeof credential.expires === "number" && credential.expires <= Date.now() + 30_000) return null;
|
||||||
|
return {
|
||||||
|
access: credential.access,
|
||||||
|
accountId: typeof credential.accountId === "string" ? credential.accountId : undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value: unknown): number | undefined {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === "string" && value.trim() !== "") {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectValue(value: unknown): Record<string, unknown> | undefined {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function windowSeconds(raw: Record<string, unknown>): number | undefined {
|
||||||
|
const seconds = numberValue(raw.limit_window_seconds ?? raw.windowSeconds);
|
||||||
|
if (seconds !== undefined) return seconds;
|
||||||
|
const mins = numberValue(raw.windowDurationMins ?? raw.window_duration_mins);
|
||||||
|
return mins === undefined ? undefined : mins * 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usedPercent(raw: Record<string, unknown>): number | undefined {
|
||||||
|
const value = numberValue(raw.used_percent ?? raw.usedPercent);
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
return Math.max(0, Math.min(100, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectWindows(raw: unknown, out: Array<{ seconds?: number; used: number; key: string }> = [], key = "root") {
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
raw.forEach((item, index) => collectWindows(item, out, `${key}.${index}`));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const obj = objectValue(raw);
|
||||||
|
if (!obj) return out;
|
||||||
|
const used = usedPercent(obj);
|
||||||
|
if (used !== undefined) out.push({ seconds: windowSeconds(obj), used, key });
|
||||||
|
for (const [childKey, value] of Object.entries(obj)) {
|
||||||
|
if (value && typeof value === "object") collectWindows(value, out, `${key}.${childKey}`);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickQuotaWindows(raw: unknown): { weeklyRemaining?: number; shortRemaining?: number } | null {
|
||||||
|
const windows = collectWindows(raw);
|
||||||
|
if (windows.length === 0) return null;
|
||||||
|
const weekly = windows.find((window) => window.seconds !== undefined && Math.abs(window.seconds - 604_800) <= 60 * 60)
|
||||||
|
?? windows.find((window) => /week|weekly|secondary/i.test(window.key));
|
||||||
|
const short = windows.find((window) => window.seconds !== undefined && Math.abs(window.seconds - 18_000) <= 60 * 30)
|
||||||
|
?? windows.find((window) => /five|session|primary|short/i.test(window.key));
|
||||||
|
return {
|
||||||
|
weeklyRemaining: weekly ? Math.max(0, Math.min(100, 100 - weekly.used)) : undefined,
|
||||||
|
shortRemaining: short ? Math.max(0, Math.min(100, 100 - short.used)) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeFg(theme: any, color: string, text: string): string {
|
||||||
|
try {
|
||||||
|
return theme.fg(color, text);
|
||||||
|
} catch {
|
||||||
|
return theme.fg("accent", text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextColor(percent: number): string {
|
||||||
|
if (percent >= 90) return "error";
|
||||||
|
if (percent >= 70) return "warning";
|
||||||
|
return "success";
|
||||||
|
}
|
||||||
|
|
||||||
|
function quotaColor(percent: number): string {
|
||||||
|
if (percent >= 80) return "error";
|
||||||
|
if (percent >= 50) return "warning";
|
||||||
|
return "border";
|
||||||
|
}
|
||||||
|
|
||||||
|
function bar(theme: any, width: number, percent: number | null, glyph: string, colorForPercent: (percent: number) => string): string {
|
||||||
|
const barWidth = Math.max(12, width);
|
||||||
|
if (percent === null) return theme.fg("muted", glyph.repeat(barWidth));
|
||||||
|
const clamped = Math.max(0, Math.min(100, percent));
|
||||||
|
const filled = Math.max(0, Math.min(barWidth, Math.round((clamped / 100) * barWidth)));
|
||||||
|
const empty = Math.max(0, barWidth - filled);
|
||||||
|
return safeFg(theme, colorForPercent(clamped), glyph.repeat(filled)) + theme.fg("dim", glyph.repeat(empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCodexQuota(force = false): Promise<void> {
|
||||||
|
const fresh = quotaState.status === "ok" && Date.now() - quotaState.refreshedAt < QUOTA_REFRESH_MS;
|
||||||
|
if (!force && fresh) return;
|
||||||
|
if (quotaRefreshPromise) return quotaRefreshPromise;
|
||||||
|
|
||||||
|
quotaState = { status: "loading" };
|
||||||
|
quotaRefreshPromise = (async () => {
|
||||||
|
const credentials = readCodexCredentials();
|
||||||
|
if (!credentials) {
|
||||||
|
quotaState = { status: "missing", detail: "OpenAI Codex OAuth credentials were not found or are expired" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastError = "quota unavailable";
|
||||||
|
for (const endpoint of CODEX_USAGE_ENDPOINTS) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = { Authorization: `Bearer ${credentials.access}` };
|
||||||
|
if (credentials.accountId) headers["ChatGPT-Account-Id"] = credentials.accountId;
|
||||||
|
const response = await fetch(endpoint, { headers, signal: controller.signal });
|
||||||
|
if (!response.ok) {
|
||||||
|
lastError = `${response.status} ${response.statusText}`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const windows = pickQuotaWindows(await response.json());
|
||||||
|
if (!windows || (windows.weeklyRemaining === undefined && windows.shortRemaining === undefined)) {
|
||||||
|
lastError = "response had no recognized quota windows";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const details = [];
|
||||||
|
if (windows.weeklyRemaining !== undefined) details.push(`weekly ${Math.round(windows.weeklyRemaining)}%`);
|
||||||
|
if (windows.shortRemaining !== undefined) details.push(`short ${Math.round(windows.shortRemaining)}%`);
|
||||||
|
quotaState = {
|
||||||
|
status: "ok",
|
||||||
|
detail: `Codex quota remaining: ${details.join(", ")}`,
|
||||||
|
weeklyRemaining: windows.weeklyRemaining,
|
||||||
|
shortRemaining: windows.shortRemaining,
|
||||||
|
refreshedAt: Date.now(),
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error.message : String(error);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
quotaState = { status: "error", detail: `Codex quota failed: ${lastError}`, refreshedAt: Date.now() };
|
||||||
|
})().finally(() => {
|
||||||
|
quotaRefreshPromise = null;
|
||||||
|
});
|
||||||
|
return quotaRefreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLines(ctx: any, theme: any, width: number): string[] {
|
||||||
|
const cwd = ctx.sessionManager?.getCwd?.() ?? ctx.cwd ?? process.cwd();
|
||||||
|
const branch = gitBranch(cwd);
|
||||||
|
const where = branch ? ` ${shortCwd(cwd)} ${branch}` : ` ${shortCwd(cwd)}`;
|
||||||
|
const model = ctx.model?.id ?? process.env.PI_MODEL ?? "no-model";
|
||||||
|
const thinking = ctx.thinkingLevel ?? process.env.PI_REASONING_LEVEL ?? "off";
|
||||||
|
const left = theme.fg("accent", where);
|
||||||
|
const right = theme.fg("dim", `${model} • ${thinking}`);
|
||||||
|
const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right)));
|
||||||
|
const contextPercentRaw = ctx.getContextUsage?.()?.percent;
|
||||||
|
const contextPercent = typeof contextPercentRaw === "number" && Number.isFinite(contextPercentRaw) ? contextPercentRaw : null;
|
||||||
|
const quotaConsumed = quotaState.status === "ok" && quotaState.weeklyRemaining !== undefined
|
||||||
|
? 100 - quotaState.weeklyRemaining
|
||||||
|
: null;
|
||||||
|
return [
|
||||||
|
truncateToWidth(left + pad + right, width),
|
||||||
|
bar(theme, width, contextPercent, "▃", contextColor),
|
||||||
|
bar(theme, width, quotaConsumed, "▔", quotaColor),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCompactStatusUi(ctx: any) {
|
||||||
|
if (!ctx.hasUI) return;
|
||||||
|
ctx.ui.setWidget("compact-status", (_tui: any, theme: any) => ({
|
||||||
|
invalidate() {},
|
||||||
|
render(width: number) {
|
||||||
|
return statusLines(ctx, theme, width);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
ctx.ui.setFooter(() => ({ invalidate() {}, render: () => [] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function compactStatus(pi: ExtensionAPI) {
|
||||||
|
function refreshUi(ctx: any) {
|
||||||
|
setCompactStatusUi(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
pi.on("session_start", (_event, ctx) => {
|
||||||
|
refreshUi(ctx);
|
||||||
|
void fetchCodexQuota(false).then(() => refreshUi(ctx));
|
||||||
|
});
|
||||||
|
pi.on("model_select", (_event, ctx) => refreshUi(ctx));
|
||||||
|
pi.on("agent_settled", (_event, ctx) => refreshUi(ctx));
|
||||||
|
|
||||||
|
pi.registerCommand("codex-quota", {
|
||||||
|
description: "Refresh and show ChatGPT Codex quota",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
refreshUi(ctx);
|
||||||
|
await fetchCodexQuota(true);
|
||||||
|
refreshUi(ctx);
|
||||||
|
const level = quotaState.status === "ok" ? "info" : quotaState.status === "missing" ? "warning" : "error";
|
||||||
|
ctx.ui.notify(quotaState.status === "idle" || quotaState.status === "loading" ? "Codex quota refresh in progress" : quotaState.detail, level);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
141
modules/agents/pi/extensions/subagents/agents.ts
Normal file
141
modules/agents/pi/extensions/subagents/agents.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { basename, join } from "node:path";
|
||||||
|
import type { ContextMode } from "./types.ts";
|
||||||
|
import type { Diagnostics } from "./config.ts";
|
||||||
|
|
||||||
|
export interface AgentDefinition {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
body: string;
|
||||||
|
context?: ContextMode;
|
||||||
|
model?: string;
|
||||||
|
thinking?: string;
|
||||||
|
tools?: string;
|
||||||
|
allowedContexts?: ContextMode[];
|
||||||
|
hidden?: boolean;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadAgents(cwd: string, projectTrusted: boolean, diagnostics: Diagnostics, agentDir = defaultAgentDir()): Map<string, AgentDefinition> {
|
||||||
|
const user = loadTier(join(agentDir, "agents"), "user", diagnostics);
|
||||||
|
const project = projectTrusted ? loadTier(join(cwd, ".pi", "agents"), "project", diagnostics) : new Map<string, AgentDefinition>();
|
||||||
|
return new Map([...user, ...project]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTier(dir: string, tier: string, diagnostics: Diagnostics): Map<string, AgentDefinition> {
|
||||||
|
const agents = new Map<string, AgentDefinition>();
|
||||||
|
if (!existsSync(dir)) return agents;
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
|
||||||
|
const path = join(dir, entry.name);
|
||||||
|
const parsed = parseAgent(path, diagnostics);
|
||||||
|
if (!parsed) continue;
|
||||||
|
if (agents.has(parsed.name)) {
|
||||||
|
diagnostics.warnings.push(`Duplicate ${tier} agent '${parsed.name}' ignored at ${path}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const stem = basename(entry.name, ".md");
|
||||||
|
if (stem !== parsed.name) diagnostics.warnings.push(`${tier} agent file '${entry.name}' name '${parsed.name}' does not match filename`);
|
||||||
|
agents.set(parsed.name, parsed);
|
||||||
|
}
|
||||||
|
return agents;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAgent(path: string, diagnostics: Diagnostics): AgentDefinition | undefined {
|
||||||
|
try {
|
||||||
|
const text = readFileSync(path, "utf8");
|
||||||
|
const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/u.exec(text);
|
||||||
|
if (!match) {
|
||||||
|
diagnostics.warnings.push(`Agent ${path} missing YAML frontmatter`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const frontmatter = parseFrontmatter(match[1]);
|
||||||
|
const name = stringField(frontmatter, "name");
|
||||||
|
const description = stringField(frontmatter, "description");
|
||||||
|
if (!name || !/^[a-z0-9-]+$/.test(name)) {
|
||||||
|
diagnostics.warnings.push(`Agent ${path} has invalid name`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!description) {
|
||||||
|
diagnostics.warnings.push(`Agent ${path} has invalid description`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const context = contextField(frontmatter.context);
|
||||||
|
const allowedContexts = contextsField(frontmatter.allowedContexts);
|
||||||
|
if (frontmatter.context !== undefined && !context) diagnostics.warnings.push(`Agent ${path} has invalid context`);
|
||||||
|
if (frontmatter.allowedContexts !== undefined && !allowedContexts) diagnostics.warnings.push(`Agent ${path} has invalid allowedContexts`);
|
||||||
|
if (context && allowedContexts && !allowedContexts.includes(context)) diagnostics.warnings.push(`Agent ${path} context is outside allowedContexts`);
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
body: match[2].trim(),
|
||||||
|
context,
|
||||||
|
model: stringField(frontmatter, "model"),
|
||||||
|
thinking: stringField(frontmatter, "thinking"),
|
||||||
|
tools: stringField(frontmatter, "tools"),
|
||||||
|
allowedContexts,
|
||||||
|
hidden: booleanField(frontmatter, "hidden"),
|
||||||
|
source: path,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
diagnostics.warnings.push(`Failed to load agent ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFrontmatter(text: string): Record<string, unknown> {
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
const lines = text.split(/\r?\n/u);
|
||||||
|
for (let i = 0; i < lines.length; i += 1) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (!line.trim() || line.trimStart().startsWith("#")) continue;
|
||||||
|
const scalar = /^(\w+):\s*(.*?)\s*$/u.exec(line);
|
||||||
|
if (!scalar) continue;
|
||||||
|
const [, key, raw] = scalar;
|
||||||
|
if (raw !== "") {
|
||||||
|
result[key] = parseScalar(raw);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const values: string[] = [];
|
||||||
|
while (i + 1 < lines.length) {
|
||||||
|
const item = /^\s+-\s*(.*?)\s*$/u.exec(lines[i + 1]);
|
||||||
|
if (!item) break;
|
||||||
|
values.push(String(parseScalar(item[1])));
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
result[key] = values;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseScalar(raw: string): string | boolean {
|
||||||
|
const unquoted = raw.replace(/^['"]|['"]$/gu, "");
|
||||||
|
if (unquoted === "true") return true;
|
||||||
|
if (unquoted === "false") return false;
|
||||||
|
return unquoted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringField(record: Record<string, unknown>, key: string): string | undefined {
|
||||||
|
const value = record[key];
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function booleanField(record: Record<string, unknown>, key: string): boolean | undefined {
|
||||||
|
const value = record[key];
|
||||||
|
return typeof value === "boolean" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextField(value: unknown): ContextMode | undefined {
|
||||||
|
return value === "independent" || value === "fork" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextsField(value: unknown): ContextMode[] | undefined {
|
||||||
|
if (!Array.isArray(value)) return undefined;
|
||||||
|
const contexts = value.map(contextField);
|
||||||
|
return contexts.every(Boolean) ? (contexts as ContextMode[]) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultAgentDir(): string {
|
||||||
|
return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
||||||
|
}
|
||||||
139
modules/agents/pi/extensions/subagents/config.test.ts
Normal file
139
modules/agents/pi/extensions/subagents/config.test.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import { loadAgents } from "./agents.ts";
|
||||||
|
import { BUILT_IN_TOOL_PROFILES, loadConfig, resolveSpawn, type Diagnostics } from "./config.ts";
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "subagents-config-"));
|
||||||
|
const agentDir = join(root, "agent");
|
||||||
|
const cwd = join(root, "project");
|
||||||
|
mkdirSync(agentDir, { recursive: true });
|
||||||
|
mkdirSync(cwd, { recursive: true });
|
||||||
|
return { root, agentDir, cwd };
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnostics(): Diagnostics {
|
||||||
|
return { warnings: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("missing config files and agent directories are normal", () => {
|
||||||
|
const { cwd, agentDir } = fixture();
|
||||||
|
const diag = diagnostics();
|
||||||
|
|
||||||
|
const config = loadConfig(cwd, true, diag, agentDir);
|
||||||
|
const agents = loadAgents(cwd, true, diag, agentDir);
|
||||||
|
|
||||||
|
assert.equal(config.defaultContext, "independent");
|
||||||
|
assert.equal(config.defaultTools, "read-only");
|
||||||
|
assert.equal(config.recentTerminalTtlMs, 300000);
|
||||||
|
assert.equal(agents.size, 0);
|
||||||
|
assert.deepEqual(diag.warnings, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("global and trusted project config merge in order", () => {
|
||||||
|
const { cwd, agentDir } = fixture();
|
||||||
|
mkdirSync(join(cwd, ".pi"), { recursive: true });
|
||||||
|
writeFileSync(join(agentDir, "subagents.json"), JSON.stringify({ defaultTools: "global-profile", recentTerminalTtlMs: 1000, toolProfiles: { "global-profile": { activeTools: ["read"] } } }));
|
||||||
|
writeFileSync(join(cwd, ".pi", "subagents.json"), JSON.stringify({ defaultTools: "project-profile", recentTerminalTtlMs: 2000, toolProfiles: { "project-profile": { activeTools: ["ls"] } } }));
|
||||||
|
|
||||||
|
const config = loadConfig(cwd, true, diagnostics(), agentDir);
|
||||||
|
|
||||||
|
assert.equal(config.defaultTools, "project-profile");
|
||||||
|
assert.equal(config.recentTerminalTtlMs, 2000);
|
||||||
|
assert.deepEqual(config.toolProfiles["global-profile"].activeTools, ["read"]);
|
||||||
|
assert.deepEqual(config.toolProfiles["project-profile"].activeTools, ["ls"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recent terminal ttl preserves zero and rejects invalid values", () => {
|
||||||
|
const { cwd, agentDir } = fixture();
|
||||||
|
writeFileSync(join(agentDir, "subagents.json"), JSON.stringify({ recentTerminalTtlMs: 0 }));
|
||||||
|
const zeroDiag = diagnostics();
|
||||||
|
|
||||||
|
const zeroConfig = loadConfig(cwd, true, zeroDiag, agentDir);
|
||||||
|
|
||||||
|
assert.equal(zeroConfig.recentTerminalTtlMs, 0);
|
||||||
|
assert.deepEqual(zeroDiag.warnings, []);
|
||||||
|
|
||||||
|
writeFileSync(join(agentDir, "subagents.json"), JSON.stringify({ recentTerminalTtlMs: -1 }));
|
||||||
|
const invalidDiag = diagnostics();
|
||||||
|
|
||||||
|
const invalidConfig = loadConfig(cwd, true, invalidDiag, agentDir);
|
||||||
|
|
||||||
|
assert.equal(invalidConfig.recentTerminalTtlMs, 300000);
|
||||||
|
assert.ok(invalidDiag.warnings.some((warning) => warning.includes("Invalid global recentTerminalTtlMs ignored")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("project config is ignored when project is untrusted", () => {
|
||||||
|
const { cwd, agentDir } = fixture();
|
||||||
|
mkdirSync(join(cwd, ".pi"), { recursive: true });
|
||||||
|
writeFileSync(join(cwd, ".pi", "subagents.json"), JSON.stringify({ defaultTools: "project-profile", toolProfiles: { "project-profile": { activeTools: ["ls"] } } }));
|
||||||
|
|
||||||
|
const config = loadConfig(cwd, false, diagnostics(), agentDir);
|
||||||
|
|
||||||
|
assert.equal(config.defaultTools, "read-only");
|
||||||
|
assert.equal(config.toolProfiles["project-profile"], undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("agents load with project precedence over user", () => {
|
||||||
|
const { cwd, agentDir } = fixture();
|
||||||
|
mkdirSync(join(agentDir, "agents"), { recursive: true });
|
||||||
|
mkdirSync(join(cwd, ".pi", "agents"), { recursive: true });
|
||||||
|
writeFileSync(join(agentDir, "agents", "review.md"), "---\nname: review\ndescription: User review\ntools: read-only\n---\nuser body\n");
|
||||||
|
writeFileSync(join(cwd, ".pi", "agents", "review.md"), "---\nname: review\ndescription: Project review\ntools: full-tools\n---\nproject body\n");
|
||||||
|
|
||||||
|
const agents = loadAgents(cwd, true, diagnostics(), agentDir);
|
||||||
|
|
||||||
|
assert.equal(agents.get("review")?.description, "Project review");
|
||||||
|
assert.equal(agents.get("review")?.body, "project body");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("duplicate same-tier definitions and invalid frontmatter produce diagnostics", () => {
|
||||||
|
const { cwd, agentDir } = fixture();
|
||||||
|
const dir = join(agentDir, "agents");
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(join(dir, "one.md"), "---\nname: same\ndescription: One\n---\none\n");
|
||||||
|
writeFileSync(join(dir, "two.md"), "---\nname: same\ndescription: Two\n---\ntwo\n");
|
||||||
|
writeFileSync(join(dir, "bad.md"), "---\nname: Bad Name\n---\nbad\n");
|
||||||
|
const diag = diagnostics();
|
||||||
|
|
||||||
|
const agents = loadAgents(cwd, true, diag, agentDir);
|
||||||
|
|
||||||
|
assert.equal(agents.size, 1);
|
||||||
|
assert.ok(diag.warnings.some((warning) => warning.includes("Duplicate user agent 'same'")));
|
||||||
|
assert.ok(diag.warnings.some((warning) => warning.includes("invalid name")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("named spawn resolves overrides, frontmatter, config, and defaults", () => {
|
||||||
|
const { cwd, agentDir } = fixture();
|
||||||
|
mkdirSync(join(agentDir, "agents"), { recursive: true });
|
||||||
|
writeFileSync(join(agentDir, "subagents.json"), JSON.stringify({ defaultTools: "local-review", toolProfiles: { "local-review": { activeTools: ["read"] } } }));
|
||||||
|
writeFileSync(join(agentDir, "agents", "review.md"), "---\nname: review\ndescription: Review\ncontext: independent\nmodel: inherit\nthinking: high\ntools: local-review\n---\nagent body\n");
|
||||||
|
const diag = diagnostics();
|
||||||
|
const config = loadConfig(cwd, true, diag, agentDir);
|
||||||
|
const agents = loadAgents(cwd, true, diag, agentDir);
|
||||||
|
|
||||||
|
const resolved = resolveSpawn({ agent: "review", prompt: "check this", label: "Review migration", thinking: "low" }, config, agents);
|
||||||
|
|
||||||
|
assert.equal(resolved.prompt, "check this");
|
||||||
|
assert.equal(resolved.label, "Review migration");
|
||||||
|
assert.equal(resolved.context, "independent");
|
||||||
|
assert.equal(resolved.model, "inherit");
|
||||||
|
assert.equal(resolved.thinking, "low");
|
||||||
|
assert.equal(resolved.tools, "local-review");
|
||||||
|
assert.deepEqual(resolved.toolProfile.activeTools, ["read"]);
|
||||||
|
assert.equal(resolved.agentBody, "agent body");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("built-in tool profile names cannot be overridden", () => {
|
||||||
|
const { cwd, agentDir } = fixture();
|
||||||
|
writeFileSync(join(agentDir, "subagents.json"), JSON.stringify({ toolProfiles: { "read-only": { activeTools: ["bash"] } } }));
|
||||||
|
const diag = diagnostics();
|
||||||
|
|
||||||
|
const config = loadConfig(cwd, true, diag, agentDir);
|
||||||
|
|
||||||
|
assert.deepEqual(config.toolProfiles["read-only"], BUILT_IN_TOOL_PROFILES["read-only"]);
|
||||||
|
assert.ok(diag.warnings.some((warning) => warning.includes("Ignoring global override for built-in tool profile 'read-only'")));
|
||||||
|
});
|
||||||
182
modules/agents/pi/extensions/subagents/config.ts
Normal file
182
modules/agents/pi/extensions/subagents/config.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { ContextMode, SpawnRequest, ToolProfile } from "./types.ts";
|
||||||
|
import type { AgentDefinition } from "./agents.ts";
|
||||||
|
|
||||||
|
export interface Diagnostics {
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubagentsConfig {
|
||||||
|
defaultContext: ContextMode;
|
||||||
|
defaultTools: string;
|
||||||
|
maxConcurrent: number;
|
||||||
|
recentTerminalTtlMs: number;
|
||||||
|
ui: {
|
||||||
|
enabled: boolean;
|
||||||
|
defaultExpanded: boolean;
|
||||||
|
};
|
||||||
|
toolProfiles: Record<string, ToolProfile>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedSpawnRequest extends SpawnRequest {
|
||||||
|
prompt: string;
|
||||||
|
context: ContextMode;
|
||||||
|
tools: string;
|
||||||
|
toolProfile: ToolProfile;
|
||||||
|
agentBody?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BUILT_IN_TOOL_PROFILES: Record<string, ToolProfile> = {
|
||||||
|
none: { activeTools: [] },
|
||||||
|
"read-only": { activeTools: ["read", "grep", "find", "ls"] },
|
||||||
|
"read-only-with-safe-bash": { activeTools: ["read", "grep", "find", "ls", "bash"] },
|
||||||
|
"full-tools": { activeTools: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: SubagentsConfig = {
|
||||||
|
defaultContext: "independent",
|
||||||
|
defaultTools: "read-only",
|
||||||
|
maxConcurrent: 3,
|
||||||
|
recentTerminalTtlMs: 5 * 60 * 1000,
|
||||||
|
ui: { enabled: true, defaultExpanded: false },
|
||||||
|
toolProfiles: { ...BUILT_IN_TOOL_PROFILES },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function loadConfig(cwd: string, projectTrusted: boolean, diagnostics: Diagnostics, agentDir = defaultAgentDir()): SubagentsConfig {
|
||||||
|
let config = cloneConfig(DEFAULT_CONFIG);
|
||||||
|
config = mergeConfig(config, readConfig(join(agentDir, "subagents.json"), diagnostics, "global"), diagnostics, "global");
|
||||||
|
if (projectTrusted) {
|
||||||
|
config = mergeConfig(config, readConfig(join(cwd, ".pi", "subagents.json"), diagnostics, "project"), diagnostics, "project");
|
||||||
|
}
|
||||||
|
if (!config.toolProfiles[config.defaultTools]) {
|
||||||
|
diagnostics.warnings.push(`Unknown defaultTools profile '${config.defaultTools}', using read-only`);
|
||||||
|
config.defaultTools = "read-only";
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSpawn(request: SpawnRequest, config: SubagentsConfig, agents: Map<string, AgentDefinition>): ResolvedSpawnRequest {
|
||||||
|
const prompt = typeof request.prompt === "string" ? request.prompt.trim() : "";
|
||||||
|
if (!prompt) throw new Error("prompt is required");
|
||||||
|
const agent = request.agent ? agents.get(request.agent) : undefined;
|
||||||
|
if (request.agent && !agent) throw new Error(`unknown subagent agent: ${request.agent}`);
|
||||||
|
|
||||||
|
const context = request.context ?? agent?.context ?? config.defaultContext;
|
||||||
|
if (context !== "independent" && context !== "fork") throw new Error(`unsupported context: ${context}`);
|
||||||
|
if (agent?.allowedContexts && !agent.allowedContexts.includes(context)) {
|
||||||
|
throw new Error(`agent '${agent.name}' does not allow ${context} context`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tools = request.tools ?? agent?.tools ?? config.defaultTools;
|
||||||
|
const toolProfile = config.toolProfiles[tools];
|
||||||
|
if (!toolProfile) throw new Error(`unknown tool profile: ${tools}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...request,
|
||||||
|
prompt,
|
||||||
|
agent: agent?.name ?? request.agent,
|
||||||
|
context,
|
||||||
|
model: request.model ?? agent?.model,
|
||||||
|
thinking: request.thinking ?? agent?.thinking,
|
||||||
|
tools,
|
||||||
|
toolProfile,
|
||||||
|
agentBody: agent?.body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readConfig(path: string, diagnostics: Diagnostics, label: string): Partial<SubagentsConfig> | undefined {
|
||||||
|
if (!existsSync(path)) return undefined;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
||||||
|
return normalizeConfig(parsed, diagnostics, label);
|
||||||
|
} catch (error) {
|
||||||
|
diagnostics.warnings.push(`Invalid ${label} subagents.json: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConfig(raw: unknown, diagnostics: Diagnostics, label: string): Partial<SubagentsConfig> | undefined {
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
diagnostics.warnings.push(`Invalid ${label} subagents.json: root must be an object`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const input = raw as Record<string, unknown>;
|
||||||
|
const config: Partial<SubagentsConfig> = {};
|
||||||
|
if (input.defaultContext === "independent" || input.defaultContext === "fork") config.defaultContext = input.defaultContext;
|
||||||
|
else if (input.defaultContext !== undefined) diagnostics.warnings.push(`Invalid ${label} defaultContext ignored`);
|
||||||
|
if (typeof input.defaultTools === "string") config.defaultTools = input.defaultTools;
|
||||||
|
else if (input.defaultTools !== undefined) diagnostics.warnings.push(`Invalid ${label} defaultTools ignored`);
|
||||||
|
if (typeof input.maxConcurrent === "number" && Number.isInteger(input.maxConcurrent) && input.maxConcurrent > 0) config.maxConcurrent = input.maxConcurrent;
|
||||||
|
else if (input.maxConcurrent !== undefined) diagnostics.warnings.push(`Invalid ${label} maxConcurrent ignored`);
|
||||||
|
if (typeof input.recentTerminalTtlMs === "number" && Number.isInteger(input.recentTerminalTtlMs) && input.recentTerminalTtlMs >= 0) {
|
||||||
|
config.recentTerminalTtlMs = input.recentTerminalTtlMs;
|
||||||
|
} else if (input.recentTerminalTtlMs !== undefined) diagnostics.warnings.push(`Invalid ${label} recentTerminalTtlMs ignored`);
|
||||||
|
if (input.ui !== undefined) config.ui = normalizeUi(input.ui, diagnostics, label);
|
||||||
|
if (input.toolProfiles !== undefined) config.toolProfiles = normalizeProfiles(input.toolProfiles, diagnostics, label);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUi(raw: unknown, diagnostics: Diagnostics, label: string): SubagentsConfig["ui"] | undefined {
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
diagnostics.warnings.push(`Invalid ${label} ui ignored`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const input = raw as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
enabled: typeof input.enabled === "boolean" ? input.enabled : DEFAULT_CONFIG.ui.enabled,
|
||||||
|
defaultExpanded: typeof input.defaultExpanded === "boolean" ? input.defaultExpanded : DEFAULT_CONFIG.ui.defaultExpanded,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProfiles(raw: unknown, diagnostics: Diagnostics, label: string): Record<string, ToolProfile> {
|
||||||
|
const profiles: Record<string, ToolProfile> = {};
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
diagnostics.warnings.push(`Invalid ${label} toolProfiles ignored`);
|
||||||
|
return profiles;
|
||||||
|
}
|
||||||
|
for (const [name, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||||
|
if (name in BUILT_IN_TOOL_PROFILES) {
|
||||||
|
diagnostics.warnings.push(`Ignoring ${label} override for built-in tool profile '${name}'`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const profile = normalizeProfile(value);
|
||||||
|
if (!profile) {
|
||||||
|
diagnostics.warnings.push(`Invalid ${label} tool profile '${name}' ignored`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
profiles[name] = profile;
|
||||||
|
}
|
||||||
|
return profiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProfile(raw: unknown): ToolProfile | undefined {
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
||||||
|
const activeTools = (raw as { activeTools?: unknown }).activeTools;
|
||||||
|
if (!Array.isArray(activeTools) || !activeTools.every((tool) => typeof tool === "string")) return undefined;
|
||||||
|
return { activeTools };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeConfig(base: SubagentsConfig, override: Partial<SubagentsConfig> | undefined, diagnostics: Diagnostics, label: string): SubagentsConfig {
|
||||||
|
if (!override) return base;
|
||||||
|
const merged = cloneConfig(base);
|
||||||
|
if (override.defaultContext) merged.defaultContext = override.defaultContext;
|
||||||
|
if (override.defaultTools) merged.defaultTools = override.defaultTools;
|
||||||
|
if (override.maxConcurrent) merged.maxConcurrent = override.maxConcurrent;
|
||||||
|
if (override.recentTerminalTtlMs !== undefined) merged.recentTerminalTtlMs = override.recentTerminalTtlMs;
|
||||||
|
if (override.ui) merged.ui = { ...merged.ui, ...override.ui };
|
||||||
|
if (override.toolProfiles) merged.toolProfiles = { ...merged.toolProfiles, ...override.toolProfiles };
|
||||||
|
for (const key of Object.keys(merged.toolProfiles)) {
|
||||||
|
if (key in BUILT_IN_TOOL_PROFILES) merged.toolProfiles[key] = BUILT_IN_TOOL_PROFILES[key];
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneConfig(config: SubagentsConfig): SubagentsConfig {
|
||||||
|
return { ...config, ui: { ...config.ui }, toolProfiles: { ...config.toolProfiles } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultAgentDir(): string {
|
||||||
|
return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
||||||
|
}
|
||||||
326
modules/agents/pi/extensions/subagents/index.ts
Normal file
326
modules/agents/pi/extensions/subagents/index.ts
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { Type } from "typebox";
|
||||||
|
import { loadAgents } from "./agents.ts";
|
||||||
|
import { loadConfig, resolveSpawn, type Diagnostics } from "./config.ts";
|
||||||
|
import { SubprocessRpcRunner } from "./runner.ts";
|
||||||
|
import { Supervisor } from "./supervisor.ts";
|
||||||
|
import { milestoneNotification } from "./status.ts";
|
||||||
|
import type { SpawnRequest, SubagentStatus } from "./types.ts";
|
||||||
|
import { widget } from "./ui.ts";
|
||||||
|
|
||||||
|
let supervisor: Supervisor | undefined;
|
||||||
|
let lastDiagnostics: Diagnostics = { warnings: [] };
|
||||||
|
let lastStatuses: SubagentStatus[] = [];
|
||||||
|
let uiExpanded = false;
|
||||||
|
|
||||||
|
export default function subagents(pi: ExtensionAPI) {
|
||||||
|
const getSupervisor = (ctx: ExtensionContext): Supervisor => {
|
||||||
|
if (supervisor) return supervisor;
|
||||||
|
const diagnostics: Diagnostics = { warnings: [] };
|
||||||
|
const cwd = cwdOf(ctx);
|
||||||
|
const config = loadConfig(cwd, isProjectTrusted(ctx), diagnostics);
|
||||||
|
lastDiagnostics = diagnostics;
|
||||||
|
uiExpanded = config.ui.defaultExpanded;
|
||||||
|
supervisor = new Supervisor(new SubprocessRpcRunner(), cwd, {
|
||||||
|
maxConcurrent: config.maxConcurrent,
|
||||||
|
recentTerminalTtlMs: config.recentTerminalTtlMs,
|
||||||
|
onMilestone: (status, event) => {
|
||||||
|
pi.appendEntry("subagent_milestone", { event, status });
|
||||||
|
const notification = milestoneNotification(status, event);
|
||||||
|
if (notification) ctx.ui?.notify?.(notification.message, notification.level);
|
||||||
|
},
|
||||||
|
onChange: (statuses) => {
|
||||||
|
lastStatuses = statuses;
|
||||||
|
updateUi(ctx, config.ui.enabled);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
updateUi(ctx, config.ui.enabled);
|
||||||
|
return supervisor;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolve = (ctx: ExtensionContext, request: SpawnRequest): SpawnRequest => {
|
||||||
|
const diagnostics: Diagnostics = { warnings: [] };
|
||||||
|
const cwd = cwdOf(ctx);
|
||||||
|
const trusted = isProjectTrusted(ctx);
|
||||||
|
const config = loadConfig(cwd, trusted, diagnostics);
|
||||||
|
const agents = loadAgents(cwd, trusted, diagnostics);
|
||||||
|
lastDiagnostics = diagnostics;
|
||||||
|
const resolved = resolveSpawn(request, config, agents);
|
||||||
|
if (resolved.context === "fork") resolved.parentSessionFile = ctx.sessionManager.getSessionFile();
|
||||||
|
return resolved;
|
||||||
|
};
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "subagent_spawn",
|
||||||
|
label: "Spawn subagent",
|
||||||
|
description: "Start one ad hoc independent subagent and return immediately with its child id",
|
||||||
|
parameters: Type.Object({
|
||||||
|
prompt: Type.String({ description: "Prompt for the delegated subagent" }),
|
||||||
|
label: Type.Optional(Type.String({ description: "Human-readable label for this work item" })),
|
||||||
|
agent: Type.Optional(Type.String({ description: "Named agent definition to use" })),
|
||||||
|
context: Type.Optional(Type.Union([Type.Literal("independent"), Type.Literal("fork")])),
|
||||||
|
model: Type.Optional(Type.String({ description: "Optional model selector for the child" })),
|
||||||
|
thinking: Type.Optional(Type.String({ description: "Optional thinking level for the child" })),
|
||||||
|
tools: Type.Optional(Type.String({ description: "Tool profile name" })),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
const accepted = getSupervisor(ctx).spawn(resolve(ctx, params as SpawnRequest));
|
||||||
|
ctx.ui?.notify?.(`Started subagent ${accepted.label}`, "info");
|
||||||
|
return textResult(accepted);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "subagent_batch",
|
||||||
|
label: "Spawn subagent batch",
|
||||||
|
description: "Start multiple subagents and return immediately with accepted child ids and per-entry failures",
|
||||||
|
parameters: Type.Object({
|
||||||
|
subagents: Type.Array(
|
||||||
|
Type.Object({
|
||||||
|
prompt: Type.String({ description: "Prompt for the delegated subagent" }),
|
||||||
|
label: Type.Optional(Type.String({ description: "Human-readable label for this work item" })),
|
||||||
|
agent: Type.Optional(Type.String({ description: "Named agent definition to use" })),
|
||||||
|
context: Type.Optional(Type.Union([Type.Literal("independent"), Type.Literal("fork")])),
|
||||||
|
model: Type.Optional(Type.String({ description: "Optional model selector for the child" })),
|
||||||
|
thinking: Type.Optional(Type.String({ description: "Optional thinking level for the child" })),
|
||||||
|
tools: Type.Optional(Type.String({ description: "Tool profile name" })),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
const requests = Array.isArray((params as { subagents?: unknown }).subagents) ? ((params as { subagents: SpawnRequest[] }).subagents) : [];
|
||||||
|
const accepted: SpawnRequest[] = [];
|
||||||
|
const failed: Array<{ index: number; error: string }> = [];
|
||||||
|
requests.forEach((request, index) => {
|
||||||
|
try {
|
||||||
|
accepted.push(resolve(ctx, request));
|
||||||
|
} catch (error) {
|
||||||
|
failed.push({ index, error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const result = getSupervisor(ctx).spawnBatch(accepted);
|
||||||
|
return textResult({ accepted: result.accepted, failed: [...failed, ...result.failed] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "subagent_list",
|
||||||
|
label: "List subagents",
|
||||||
|
description: "List active and terminal subagents for this parent session until terminal entries are cleared",
|
||||||
|
parameters: Type.Object({}),
|
||||||
|
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
||||||
|
return textResult(getSupervisor(ctx).list());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "subagent_status",
|
||||||
|
label: "Get subagent status",
|
||||||
|
description: "Get current lifecycle status for one subagent",
|
||||||
|
parameters: Type.Object({
|
||||||
|
id: Type.String({ description: "Subagent id returned by subagent_spawn" }),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
return textResult(getSupervisor(ctx).status(String((params as { id: unknown }).id)));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "subagent_result",
|
||||||
|
label: "Get subagent result",
|
||||||
|
description: "Return still-running before completion and the final answer after completion",
|
||||||
|
parameters: Type.Object({
|
||||||
|
id: Type.String({ description: "Subagent id returned by subagent_spawn" }),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
return textResult(getSupervisor(ctx).result(String((params as { id: unknown }).id)));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "subagent_wait",
|
||||||
|
label: "Wait for subagents",
|
||||||
|
description: "Block until multiple subagents are terminal or a timeout expires. Prefer setting timeoutMs so the parent turn cannot hang forever",
|
||||||
|
parameters: Type.Object({
|
||||||
|
ids: Type.Array(Type.String({ description: "Subagent id returned by subagent_spawn or subagent_batch" })),
|
||||||
|
timeoutMs: Type.Optional(Type.Number({ description: "Maximum milliseconds to wait. Omit or use 0 to wait indefinitely" })),
|
||||||
|
mode: Type.Optional(Type.Union([Type.Literal("all"), Type.Literal("any")], { description: "Wait for all ids by default, or return after any id is terminal" })),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
||||||
|
const input = params as { ids?: unknown; timeoutMs?: unknown; mode?: unknown };
|
||||||
|
const ids = Array.isArray(input.ids) ? input.ids.map(String) : [];
|
||||||
|
const timeoutMs = typeof input.timeoutMs === "number" && Number.isFinite(input.timeoutMs) ? input.timeoutMs : undefined;
|
||||||
|
const mode = input.mode === "any" ? "any" : "all";
|
||||||
|
return textResult(await getSupervisor(ctx).wait(ids, { timeoutMs, mode, signal }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "subagent_cancel",
|
||||||
|
label: "Cancel subagent",
|
||||||
|
description: "Cancel a running subagent",
|
||||||
|
parameters: Type.Object({
|
||||||
|
id: Type.String({ description: "Subagent id returned by subagent_spawn" }),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
return textResult(await getSupervisor(ctx).cancel(String((params as { id: unknown }).id)));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "subagent_clear",
|
||||||
|
label: "Clear terminal subagents",
|
||||||
|
description: "Remove terminal subagents from the current-session visible work set. Omitting ids clears all terminal children",
|
||||||
|
parameters: Type.Object({
|
||||||
|
ids: Type.Optional(Type.Array(Type.String({ description: "Subagent id returned by subagent_spawn or subagent_batch" }))),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
const input = params as { ids?: unknown };
|
||||||
|
const ids = Array.isArray(input.ids) ? input.ids.map(String) : undefined;
|
||||||
|
return textResult({ cleared: getSupervisor(ctx).clearTerminal(ids) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("subagent-spawn", {
|
||||||
|
description: "Start an ad hoc independent subagent",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const accepted = getSupervisor(ctx).spawn(resolve(ctx, parseSpawnArgs(args)));
|
||||||
|
ctx.ui.notify(`Started subagent ${accepted.label}`, "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("subagent-batch", {
|
||||||
|
description: "Start ad hoc independent subagents split by |",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const requests = args
|
||||||
|
.split("|")
|
||||||
|
.map((prompt) => prompt.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((prompt) => resolve(ctx, { prompt }));
|
||||||
|
ctx.ui.notify(JSON.stringify(getSupervisor(ctx).spawnBatch(requests), null, 2), "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("subagent-list", {
|
||||||
|
description: "Show subagent status records",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
ctx.ui.notify(JSON.stringify(getSupervisor(ctx).list(), null, 2), "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("subagent-clear", {
|
||||||
|
description: "Clear terminal subagent records. Pass ids to clear selected terminal records only",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const ids = args.trim().split(/\s+/u).filter(Boolean);
|
||||||
|
ctx.ui.notify(JSON.stringify({ cleared: getSupervisor(ctx).clearTerminal(ids.length > 0 ? ids : undefined) }, null, 2), "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("subagent-status", {
|
||||||
|
description: "Show a subagent status by id",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
ctx.ui.notify(JSON.stringify(getSupervisor(ctx).status(args.trim()), null, 2), "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("subagent-result", {
|
||||||
|
description: "Show a subagent result by id",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
ctx.ui.notify(JSON.stringify(getSupervisor(ctx).result(args.trim()), null, 2), "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("subagent-wait", {
|
||||||
|
description: "Wait for subagent ids separated by spaces",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const { ids, timeoutMs, mode } = parseWaitArgs(args);
|
||||||
|
ctx.ui.notify(JSON.stringify(await getSupervisor(ctx).wait(ids, { timeoutMs, mode }), null, 2), "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("subagent-ui", {
|
||||||
|
description: "Toggle the bundled subagent status inspector",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
uiExpanded = !uiExpanded;
|
||||||
|
updateUi(ctx, true);
|
||||||
|
ctx.ui.notify(`Subagent inspector ${uiExpanded ? "expanded" : "collapsed"}`, "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("subagent-diagnostics", {
|
||||||
|
description: "Show subagent configuration diagnostics from the last load",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
ctx.ui.notify(JSON.stringify(lastDiagnostics, null, 2), "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("subagent-cancel", {
|
||||||
|
description: "Cancel a running subagent by id",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
ctx.ui.notify(JSON.stringify(await getSupervisor(ctx).cancel(args.trim()), null, 2), "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_shutdown", async () => {
|
||||||
|
await supervisor?.shutdown();
|
||||||
|
supervisor = undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUi(ctx: ExtensionContext, enabled: boolean) {
|
||||||
|
if (!ctx.hasUI) return;
|
||||||
|
ctx.ui.setWidget("subagents", enabled ? widget(lastStatuses, uiExpanded) : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSpawnArgs(args: string): SpawnRequest {
|
||||||
|
const parts = args.trim().split(/\s+/u);
|
||||||
|
const request: Partial<SpawnRequest> = {};
|
||||||
|
while (parts.length >= 2 && parts[0].startsWith("--")) {
|
||||||
|
const flag = parts.shift();
|
||||||
|
const value = parts.shift();
|
||||||
|
if (flag === "--agent") request.agent = value;
|
||||||
|
else if (flag === "--label") request.label = value;
|
||||||
|
else if (flag === "--context" && (value === "independent" || value === "fork")) request.context = value;
|
||||||
|
else if (flag === "--tools") request.tools = value;
|
||||||
|
else if (flag === "--model") request.model = value;
|
||||||
|
else if (flag === "--thinking") request.thinking = value;
|
||||||
|
}
|
||||||
|
return { ...request, prompt: parts.join(" ") || args } as SpawnRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWaitArgs(args: string): { ids: string[]; timeoutMs?: number; mode?: "all" | "any" } {
|
||||||
|
const parts = args.trim().split(/\s+/u).filter(Boolean);
|
||||||
|
let timeoutMs: number | undefined;
|
||||||
|
let mode: "all" | "any" | undefined;
|
||||||
|
const ids: string[] = [];
|
||||||
|
while (parts.length > 0) {
|
||||||
|
const part = parts.shift();
|
||||||
|
if (!part) continue;
|
||||||
|
if (part === "--timeout-ms" && parts[0]) {
|
||||||
|
const parsed = Number(parts.shift());
|
||||||
|
if (Number.isFinite(parsed)) timeoutMs = parsed;
|
||||||
|
} else if (part === "--mode" && (parts[0] === "all" || parts[0] === "any")) {
|
||||||
|
mode = parts.shift() as "all" | "any";
|
||||||
|
} else {
|
||||||
|
ids.push(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ids, timeoutMs, mode };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProjectTrusted(ctx: ExtensionContext): boolean {
|
||||||
|
const value = (ctx as unknown as { isProjectTrusted?: () => boolean }).isProjectTrusted?.();
|
||||||
|
return value === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cwdOf(ctx: ExtensionContext): string {
|
||||||
|
const sessionCwd = (ctx as unknown as { sessionManager?: { getCwd?: () => string }; cwd?: string }).sessionManager?.getCwd?.();
|
||||||
|
return sessionCwd ?? (ctx as unknown as { cwd?: string }).cwd ?? process.cwd();
|
||||||
|
}
|
||||||
|
|
||||||
|
function textResult(value: unknown) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }],
|
||||||
|
details: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
118
modules/agents/pi/extensions/subagents/runner.test.ts
Normal file
118
modules/agents/pi/extensions/subagents/runner.test.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import childProcess from "node:child_process";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import test from "node:test";
|
||||||
|
import type { RunnerEvents } from "./types.ts";
|
||||||
|
|
||||||
|
class FakeStream extends EventEmitter {
|
||||||
|
setEncoding(_encoding: BufferEncoding): void {}
|
||||||
|
|
||||||
|
write(_chunk: string, callback?: (error?: Error | null) => void): boolean {
|
||||||
|
callback?.();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
end(): void {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function events(): RunnerEvents {
|
||||||
|
return {
|
||||||
|
accepted: () => {},
|
||||||
|
running: () => {},
|
||||||
|
settling: () => {},
|
||||||
|
completed: () => {},
|
||||||
|
failed: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("child RPC process forwards structured activity before collecting the final result", async (t) => {
|
||||||
|
const running: unknown[] = [];
|
||||||
|
const completed: Array<{ result: string; stopReason?: string }> = [];
|
||||||
|
const fakeChild = new EventEmitter() as EventEmitter & {
|
||||||
|
stdout: FakeStream;
|
||||||
|
stderr: FakeStream;
|
||||||
|
stdin: FakeStream;
|
||||||
|
killed: boolean;
|
||||||
|
pid?: number;
|
||||||
|
kill(signal?: NodeJS.Signals): boolean;
|
||||||
|
};
|
||||||
|
fakeChild.stdout = new FakeStream();
|
||||||
|
fakeChild.stderr = new FakeStream();
|
||||||
|
fakeChild.stdin = new FakeStream();
|
||||||
|
fakeChild.killed = false;
|
||||||
|
fakeChild.kill = () => {
|
||||||
|
fakeChild.killed = true;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
t.mock.method(fakeChild.stdin, "write", (chunk, callback?: (error?: Error | null) => void) => {
|
||||||
|
const request = JSON.parse(String(chunk)) as { id: string; type: string };
|
||||||
|
callback?.();
|
||||||
|
if (request.type === "get_last_assistant_text") {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
fakeChild.stdout.emit("data", `${JSON.stringify({ id: request.id, type: "response", success: true, data: { text: "final answer" } })}\n`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
t.mock.method(childProcess, "spawn", () => fakeChild as unknown as childProcess.ChildProcessWithoutNullStreams);
|
||||||
|
|
||||||
|
const { SubprocessRpcRunner } = await import("./runner.ts");
|
||||||
|
const runner = new SubprocessRpcRunner();
|
||||||
|
await runner.start("child-1", { prompt: "work", label: "Review migration" }, "/tmp", {
|
||||||
|
...events(),
|
||||||
|
running: (event) => running.push(event),
|
||||||
|
completed: (result, stopReason) => completed.push({ result, stopReason }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstActivity = { type: "message_start", role: "assistant", message: { id: "msg-1" } };
|
||||||
|
const secondActivity = { type: "tool_execution_start", tool: "read", input: { path: "runner.ts" } };
|
||||||
|
const settledActivity = { type: "agent_settled" };
|
||||||
|
fakeChild.stdout.emit("data", `${JSON.stringify(firstActivity)}\n${JSON.stringify(secondActivity)}\n${JSON.stringify(settledActivity)}\n`);
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
assert.deepEqual(running, [firstActivity, secondActivity, settledActivity]);
|
||||||
|
assert.deepEqual(completed, [{ result: "final answer", stopReason: "agent_settled" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("child RPC process disables discovery while explicitly loading subagents extension", async (t) => {
|
||||||
|
const calls: Array<{ command: string; args: string[] }> = [];
|
||||||
|
const fakeChild = new EventEmitter() as EventEmitter & {
|
||||||
|
stdout: FakeStream;
|
||||||
|
stderr: FakeStream;
|
||||||
|
stdin: FakeStream;
|
||||||
|
killed: boolean;
|
||||||
|
pid?: number;
|
||||||
|
kill(signal?: NodeJS.Signals): boolean;
|
||||||
|
};
|
||||||
|
fakeChild.stdout = new FakeStream();
|
||||||
|
fakeChild.stderr = new FakeStream();
|
||||||
|
fakeChild.stdin = new FakeStream();
|
||||||
|
fakeChild.killed = false;
|
||||||
|
fakeChild.kill = () => {
|
||||||
|
fakeChild.killed = true;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const spawn = t.mock.method(childProcess, "spawn", (command, args) => {
|
||||||
|
calls.push({ command: String(command), args: Array.isArray(args) ? args.map(String) : [] });
|
||||||
|
return fakeChild as unknown as childProcess.ChildProcessWithoutNullStreams;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { SubprocessRpcRunner } = await import("./runner.ts");
|
||||||
|
const runner = new SubprocessRpcRunner();
|
||||||
|
await runner.start("child-1", { prompt: "work", label: "Review migration" }, "/tmp", events());
|
||||||
|
|
||||||
|
assert.equal(spawn.mock.callCount(), 1);
|
||||||
|
const args = calls[0].args;
|
||||||
|
const noExtensionsIndex = args.indexOf("--no-extensions");
|
||||||
|
const extensionIndex = args.indexOf("--extension");
|
||||||
|
|
||||||
|
const nameIndex = args.indexOf("--name");
|
||||||
|
|
||||||
|
assert.notEqual(noExtensionsIndex, -1, "child args keep automatic extension discovery disabled");
|
||||||
|
assert.notEqual(nameIndex, -1, "child args include a process name");
|
||||||
|
assert.equal(args[nameIndex + 1], "subagent Review migration");
|
||||||
|
assert.notEqual(extensionIndex, -1, "child args explicitly load the subagents extension entry");
|
||||||
|
assert.equal(args[extensionIndex + 1], fileURLToPath(new URL("./index.ts", import.meta.url)));
|
||||||
|
assert.ok(noExtensionsIndex < extensionIndex);
|
||||||
|
});
|
||||||
218
modules/agents/pi/extensions/subagents/runner.ts
Normal file
218
modules/agents/pi/extensions/subagents/runner.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import type { ChildHandle, ChildRunner, RunnerEvents, SpawnRequest } from "./types.ts";
|
||||||
|
|
||||||
|
interface PendingResponse {
|
||||||
|
resolve(value: unknown): void;
|
||||||
|
reject(error: Error): void;
|
||||||
|
command: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RpcLine {
|
||||||
|
id?: string;
|
||||||
|
type?: string;
|
||||||
|
command?: string;
|
||||||
|
success?: boolean;
|
||||||
|
data?: unknown;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RpcChildHandle implements ChildHandle {
|
||||||
|
private buffer = "";
|
||||||
|
private nextRequest = 0;
|
||||||
|
private settled = false;
|
||||||
|
private finishing = false;
|
||||||
|
private cancelling = false;
|
||||||
|
private killed = false;
|
||||||
|
private readonly pending = new Map<string, PendingResponse>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly child: ChildProcessWithoutNullStreams,
|
||||||
|
private readonly events: RunnerEvents,
|
||||||
|
) {
|
||||||
|
child.stdout.setEncoding("utf8");
|
||||||
|
child.stderr.setEncoding("utf8");
|
||||||
|
child.stdout.on("data", (chunk) => this.onStdout(chunk));
|
||||||
|
child.stderr.on("data", (chunk) => this.events.running(`stderr: ${String(chunk).trim().slice(0, 200)}`));
|
||||||
|
child.on("error", (error) => this.fail(error.message));
|
||||||
|
child.on("close", (code, signal) => {
|
||||||
|
for (const pending of this.pending.values()) {
|
||||||
|
pending.reject(new Error(`RPC process closed before ${pending.command} response`));
|
||||||
|
}
|
||||||
|
this.pending.clear();
|
||||||
|
if (!this.settled) this.fail(`RPC process closed with code ${code ?? "null"} signal ${signal ?? "null"}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async prompt(message: string): Promise<void> {
|
||||||
|
await this.send("prompt", { message });
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(): Promise<void> {
|
||||||
|
if (this.cancelling) return;
|
||||||
|
this.cancelling = true;
|
||||||
|
try {
|
||||||
|
await Promise.race([this.send("abort", {}), delay(200)]);
|
||||||
|
} catch {}
|
||||||
|
this.terminate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private onStdout(chunk: string) {
|
||||||
|
this.buffer += chunk;
|
||||||
|
while (true) {
|
||||||
|
const newline = this.buffer.indexOf("\n");
|
||||||
|
if (newline === -1) return;
|
||||||
|
const line = this.buffer.slice(0, newline).replace(/\r$/, "");
|
||||||
|
this.buffer = this.buffer.slice(newline + 1);
|
||||||
|
if (line.trim() === "") continue;
|
||||||
|
this.onLine(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onLine(line: string) {
|
||||||
|
let payload: RpcLine;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
this.events.running(`non-json rpc output: ${line.slice(0, 200)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === "response" && payload.id) {
|
||||||
|
const pending = this.pending.get(payload.id);
|
||||||
|
if (!pending) return;
|
||||||
|
this.pending.delete(payload.id);
|
||||||
|
if (payload.success) pending.resolve(payload.data);
|
||||||
|
else pending.reject(new Error(payload.error ?? payload.message ?? `${pending.command} failed`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === "agent_started") {
|
||||||
|
this.events.running(payload as Record<string, unknown>);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === "agent_settled") {
|
||||||
|
this.events.running(payload as Record<string, unknown>);
|
||||||
|
this.finish().catch((error) => this.fail(error instanceof Error ? error.message : String(error)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type) this.events.running(payload as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async finish() {
|
||||||
|
if (this.settled || this.finishing) return;
|
||||||
|
this.finishing = true;
|
||||||
|
this.events.settling();
|
||||||
|
const result = await this.send("get_last_assistant_text", {});
|
||||||
|
const text = typeof result === "string" ? result : result && typeof result === "object" && "text" in result ? String((result as { text: unknown }).text) : "";
|
||||||
|
this.settled = true;
|
||||||
|
this.events.completed(text, "agent_settled");
|
||||||
|
this.terminate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private terminate() {
|
||||||
|
if (this.killed) return;
|
||||||
|
this.killed = true;
|
||||||
|
this.child.stdin.end();
|
||||||
|
if (this.child.killed) return;
|
||||||
|
if (process.platform !== "win32" && this.child.pid) {
|
||||||
|
try {
|
||||||
|
process.kill(-this.child.pid, "SIGTERM");
|
||||||
|
} catch {
|
||||||
|
this.child.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.child.killed || !this.child.pid) return;
|
||||||
|
try {
|
||||||
|
process.kill(-this.child.pid, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
this.child.kill("SIGKILL");
|
||||||
|
}
|
||||||
|
}, 2_000).unref();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.child.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
|
||||||
|
private fail(error: string) {
|
||||||
|
if (this.settled) return;
|
||||||
|
this.settled = true;
|
||||||
|
this.events.failed(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private send(command: string, body: Record<string, unknown>): Promise<unknown> {
|
||||||
|
const id = `subagent-${++this.nextRequest}`;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.pending.set(id, { resolve, reject, command });
|
||||||
|
this.child.stdin.write(`${JSON.stringify({ id, type: command, ...body })}\n`, (error) => {
|
||||||
|
if (!error) return;
|
||||||
|
this.pending.delete(id);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SubprocessRpcRunner implements ChildRunner {
|
||||||
|
async start(id: string, request: SpawnRequest, cwd: string, events: RunnerEvents): Promise<ChildHandle> {
|
||||||
|
const args = [process.argv[1], "--mode", "rpc", "--no-extensions", "--extension", subagentsExtensionPath(), "--name", `subagent ${request.label ?? id}`, ...contextArgs(request), ...toolArgs(request), ...modelArgs(request)];
|
||||||
|
const child = spawn(process.execPath, args, {
|
||||||
|
cwd,
|
||||||
|
env: childEnvironment(),
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
detached: process.platform !== "win32",
|
||||||
|
});
|
||||||
|
const handle = new RpcChildHandle(child, events);
|
||||||
|
events.accepted();
|
||||||
|
void handle.prompt(independentPrompt(request)).catch((error) => events.failed(error instanceof Error ? error.message : String(error)));
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function subagentsExtensionPath(): string {
|
||||||
|
return fileURLToPath(new URL("./index.ts", import.meta.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextArgs(request: SpawnRequest): string[] {
|
||||||
|
if (request.context !== "fork" || !request.parentSessionFile) return [];
|
||||||
|
return ["--fork", request.parentSessionFile];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolArgs(request: SpawnRequest): string[] {
|
||||||
|
const activeTools = request.toolProfile?.activeTools;
|
||||||
|
if (activeTools === undefined || activeTools === null) return [];
|
||||||
|
if (activeTools.length === 0) return ["--no-tools"];
|
||||||
|
return ["--tools", activeTools.join(",")];
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelArgs(request: SpawnRequest): string[] {
|
||||||
|
const args: string[] = [];
|
||||||
|
if (request.model && request.model !== "inherit") args.push("--model", request.model);
|
||||||
|
if (request.thinking) args.push("--thinking", request.thinking);
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function childEnvironment(): NodeJS.ProcessEnv {
|
||||||
|
const env = { ...process.env };
|
||||||
|
delete env.PI_SESSION_ID;
|
||||||
|
delete env.PI_SESSION_FILE;
|
||||||
|
delete env.PI_PROVIDER;
|
||||||
|
delete env.PI_MODEL;
|
||||||
|
delete env.PI_REASONING_LEVEL;
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
function independentPrompt(request: SpawnRequest): string {
|
||||||
|
const base = request.agentBody ? `${request.agentBody}\n\n` : "";
|
||||||
|
if (request.context === "fork") {
|
||||||
|
return `${base}You are running as a delegated subagent in fork context.\nUse the inherited parent session context, then return a concise final answer for the parent agent.\n\nTask:\n${request.prompt}`;
|
||||||
|
}
|
||||||
|
return `${base}You are running as a delegated subagent in independent context.\nDo not assume access to the parent conversation transcript.\nReturn a concise final answer for the parent agent.\n\nTask:\n${request.prompt}`;
|
||||||
|
}
|
||||||
58
modules/agents/pi/extensions/subagents/status.ts
Normal file
58
modules/agents/pi/extensions/subagents/status.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { SUBAGENT_STATES, SUBAGENT_TERMINAL_STATES } from "./types.ts";
|
||||||
|
import type { ChildRecord, SpawnAccepted, SubagentResult, SubagentState, SubagentStatus } from "./types.ts";
|
||||||
|
|
||||||
|
export function toAccepted(status: SubagentStatus): SpawnAccepted {
|
||||||
|
return {
|
||||||
|
id: status.id,
|
||||||
|
label: status.label,
|
||||||
|
context: status.context,
|
||||||
|
tools: status.tools,
|
||||||
|
state: status.state,
|
||||||
|
hint: `Use subagent_status or subagent_result with id ${status.id}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneStatus(status: SubagentStatus): SubagentStatus {
|
||||||
|
return {
|
||||||
|
...status,
|
||||||
|
currentActivity: status.currentActivity ? { ...status.currentActivity } : undefined,
|
||||||
|
activityHistory: status.activityHistory.map((event) => ({ ...event })),
|
||||||
|
elapsedMs: elapsedMs(status),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneResult(record: ChildRecord): SubagentResult {
|
||||||
|
const status = cloneStatus(record.status);
|
||||||
|
const terminal = isTerminalState(status.state);
|
||||||
|
return {
|
||||||
|
id: status.id,
|
||||||
|
label: status.label,
|
||||||
|
state: status.state,
|
||||||
|
running: !terminal,
|
||||||
|
resultAvailable: status.resultAvailable,
|
||||||
|
result: record.result,
|
||||||
|
error: status.error,
|
||||||
|
completedAt: status.completedAt,
|
||||||
|
elapsedMs: status.elapsedMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTerminalState(state: SubagentState): boolean {
|
||||||
|
return (SUBAGENT_TERMINAL_STATES as readonly string[]).includes(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function milestoneNotification(status: SubagentStatus, event: string): { message: string; level: "info" | "error" } | undefined {
|
||||||
|
if (!isSubagentState(event) || !isTerminalState(event)) return undefined;
|
||||||
|
return { message: `Subagent ${status.label} ${event}`, level: event === "completed" ? "info" : "error" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSubagentState(value: string): value is SubagentState {
|
||||||
|
return (SUBAGENT_STATES as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function elapsedMs(status: Pick<SubagentStatus, "startedAt" | "completedAt">): number {
|
||||||
|
const start = Date.parse(status.startedAt);
|
||||||
|
const end = status.completedAt ? Date.parse(status.completedAt) : Date.now();
|
||||||
|
if (!Number.isFinite(start) || !Number.isFinite(end)) return 0;
|
||||||
|
return Math.max(0, end - start);
|
||||||
|
}
|
||||||
469
modules/agents/pi/extensions/subagents/supervisor.test.ts
Normal file
469
modules/agents/pi/extensions/subagents/supervisor.test.ts
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { milestoneNotification } from "./status.ts";
|
||||||
|
import { Supervisor } from "./supervisor.ts";
|
||||||
|
import type { ChildHandle, ChildRunner, RunnerEvents, SpawnRequest } from "./types.ts";
|
||||||
|
import { widget } from "./ui.ts";
|
||||||
|
|
||||||
|
class FakeHandle implements ChildHandle {
|
||||||
|
cancelCalls = 0;
|
||||||
|
|
||||||
|
async cancel(): Promise<void> {
|
||||||
|
this.cancelCalls += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeRunner implements ChildRunner {
|
||||||
|
starts: Array<{ id: string; request: SpawnRequest; events: RunnerEvents; handle: FakeHandle }> = [];
|
||||||
|
autoAccept = true;
|
||||||
|
|
||||||
|
async start(id: string, request: SpawnRequest, _cwd: string, events: RunnerEvents): Promise<ChildHandle> {
|
||||||
|
const handle = new FakeHandle();
|
||||||
|
this.starts.push({ id, request, events, handle });
|
||||||
|
if (this.autoAccept) events.accepted(`session-${id}`);
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
async function spawnStarted(supervisor: Supervisor, prompt = "work") {
|
||||||
|
const accepted = supervisor.spawn({ prompt });
|
||||||
|
await sleep(0);
|
||||||
|
return accepted;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("cancel is idempotent and reaches cancelled", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const accepted = await spawnStarted(supervisor);
|
||||||
|
|
||||||
|
const first = await supervisor.cancel(accepted.id);
|
||||||
|
const second = await supervisor.cancel(accepted.id);
|
||||||
|
|
||||||
|
assert.equal(first.state, "cancelled");
|
||||||
|
assert.equal(second.state, "cancelled");
|
||||||
|
assert.equal(runner.starts[0].handle.cancelCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("startup timeout reaches timed_out", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
runner.autoAccept = false;
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp", { timeouts: { startMs: 5 } });
|
||||||
|
const accepted = await spawnStarted(supervisor);
|
||||||
|
|
||||||
|
await sleep(20);
|
||||||
|
|
||||||
|
const status = supervisor.status(accepted.id);
|
||||||
|
assert.equal(status.state, "timed_out");
|
||||||
|
assert.equal(status.stopReason, "start_timeout");
|
||||||
|
assert.equal(runner.starts[0].handle.cancelCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runtime timeout reaches timed_out", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp", { timeouts: { runMs: 5 } });
|
||||||
|
const accepted = await spawnStarted(supervisor);
|
||||||
|
|
||||||
|
await sleep(20);
|
||||||
|
|
||||||
|
const status = supervisor.status(accepted.id);
|
||||||
|
assert.equal(status.state, "timed_out");
|
||||||
|
assert.equal(status.stopReason, "run_timeout");
|
||||||
|
assert.equal(runner.starts[0].handle.cancelCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("activity exposes ordered transcript events while status and list keep only summaries", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const accepted = await spawnStarted(supervisor);
|
||||||
|
|
||||||
|
runner.starts[0].events.running({ type: "message_started", role: "assistant" });
|
||||||
|
runner.starts[0].events.running({
|
||||||
|
type: "message_delta",
|
||||||
|
role: "assistant",
|
||||||
|
assistantMessageEvent: { type: "content_delta", delta: "private transcript body" },
|
||||||
|
});
|
||||||
|
runner.starts[0].events.running({ type: "tool_started", tool: "read", input: { path: "secret-notes.md" } });
|
||||||
|
runner.starts[0].events.running({ type: "tool_completed", tool: "read", output: "secret file contents" });
|
||||||
|
|
||||||
|
type ActivityStatus = ReturnType<Supervisor["status"]> & {
|
||||||
|
activityHistory: Array<{ type: string; summary: string }>;
|
||||||
|
currentActivity: { summary: string };
|
||||||
|
};
|
||||||
|
const activity = supervisor.activity(accepted.id);
|
||||||
|
const status = supervisor.status(accepted.id) as ActivityStatus;
|
||||||
|
const listed = supervisor.list().find((item) => item.id === accepted.id) as ActivityStatus | undefined;
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
activity.map((event) => event.type),
|
||||||
|
["queued", "starting", "prompt accepted", "message_started", "message_delta", "tool_started", "tool_completed"],
|
||||||
|
);
|
||||||
|
assert.deepEqual(activity[4], {
|
||||||
|
type: "message_delta",
|
||||||
|
summary: "assistant message content_delta",
|
||||||
|
at: activity[4].at,
|
||||||
|
role: "assistant",
|
||||||
|
tool: undefined,
|
||||||
|
phase: "content_delta",
|
||||||
|
text: "private transcript body",
|
||||||
|
input: undefined,
|
||||||
|
output: undefined,
|
||||||
|
error: undefined,
|
||||||
|
payload: {
|
||||||
|
type: "message_delta",
|
||||||
|
role: "assistant",
|
||||||
|
assistantMessageEvent: { type: "content_delta", delta: "private transcript body" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(activity[5], {
|
||||||
|
type: "tool_started",
|
||||||
|
summary: "read secret-notes.md",
|
||||||
|
at: activity[5].at,
|
||||||
|
role: undefined,
|
||||||
|
tool: "read",
|
||||||
|
phase: "started",
|
||||||
|
text: undefined,
|
||||||
|
input: { path: "secret-notes.md" },
|
||||||
|
output: undefined,
|
||||||
|
error: undefined,
|
||||||
|
payload: { type: "tool_started", tool: "read", input: { path: "secret-notes.md" } },
|
||||||
|
});
|
||||||
|
assert.equal(activity[6].output, "secret file contents");
|
||||||
|
|
||||||
|
assert.ok(Array.isArray(status.activityHistory), "status should expose structured activityHistory");
|
||||||
|
assert.deepEqual(status.activityHistory.map((event) => event.type), activity.map((event) => event.type));
|
||||||
|
assert.deepEqual(status.activityHistory.map((event) => event.summary), activity.map((event) => event.summary));
|
||||||
|
assert.equal(status.currentActivity.summary, "read");
|
||||||
|
assert.equal(listed?.currentActivity.summary, "read");
|
||||||
|
assert.doesNotMatch(JSON.stringify(status), /private transcript body|secret file contents/u);
|
||||||
|
assert.doesNotMatch(JSON.stringify(listed), /private transcript body|secret file contents/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("status activity history keeps only the 100 most recent summaries", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const accepted = await spawnStarted(supervisor);
|
||||||
|
|
||||||
|
for (let index = 0; index < 150; index += 1) {
|
||||||
|
runner.starts[0].events.running(`tick ${index}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = supervisor.status(accepted.id).activityHistory;
|
||||||
|
|
||||||
|
assert.equal(history.length, 100);
|
||||||
|
assert.equal(history[0].summary, "tick 50");
|
||||||
|
assert.equal(history[99].summary, "tick 149");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("process failure reaches failed with diagnostics", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const accepted = await spawnStarted(supervisor);
|
||||||
|
|
||||||
|
runner.starts[0].events.failed("process closed with code 1");
|
||||||
|
|
||||||
|
const status = supervisor.status(accepted.id);
|
||||||
|
assert.equal(status.state, "failed");
|
||||||
|
assert.equal(status.error, "process closed with code 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shutdown cancels running children", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const accepted = await spawnStarted(supervisor);
|
||||||
|
|
||||||
|
await supervisor.shutdown();
|
||||||
|
|
||||||
|
const status = supervisor.status(accepted.id);
|
||||||
|
assert.equal(status.state, "cancelled");
|
||||||
|
assert.equal(status.stopReason, "shutdown");
|
||||||
|
assert.equal(runner.starts[0].handle.cancelCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("completed children ignore later cancel", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const accepted = await spawnStarted(supervisor);
|
||||||
|
|
||||||
|
runner.starts[0].events.completed("done", "agent_settled");
|
||||||
|
await supervisor.cancel(accepted.id);
|
||||||
|
|
||||||
|
const result = supervisor.result(accepted.id);
|
||||||
|
assert.equal(result.state, "completed");
|
||||||
|
assert.equal(result.result, "done");
|
||||||
|
assert.equal(runner.starts[0].handle.cancelCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("explicit labels are reused across accepted status list and result surfaces", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const label = "Review risky migration";
|
||||||
|
|
||||||
|
const accepted = supervisor.spawn({ prompt: "inspect the migration plan", label } as SpawnRequest & { label: string });
|
||||||
|
await sleep(0);
|
||||||
|
runner.starts[0].events.completed("done", "agent_settled");
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
{
|
||||||
|
accepted: accepted.label,
|
||||||
|
status: supervisor.status(accepted.id).label,
|
||||||
|
list: supervisor.list().find((status) => status.id === accepted.id)?.label,
|
||||||
|
result: (supervisor.result(accepted.id) as { label?: string }).label,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accepted: label,
|
||||||
|
status: label,
|
||||||
|
list: label,
|
||||||
|
result: label,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ad hoc fallback labels are prompt-derived and reused by widget and result surfaces", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const prompt = " Audit\n\tguest enablement plan ";
|
||||||
|
const label = "Audit guest enablement plan";
|
||||||
|
|
||||||
|
const accepted = supervisor.spawn({ prompt });
|
||||||
|
await sleep(0);
|
||||||
|
runner.starts[0].events.completed("done", "agent_settled");
|
||||||
|
const statuses = supervisor.list();
|
||||||
|
const inspectorLines = widget(statuses, true)().render(240);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
{
|
||||||
|
accepted: accepted.label,
|
||||||
|
childRequest: runner.starts[0].request.label,
|
||||||
|
status: supervisor.status(accepted.id).label,
|
||||||
|
list: statuses.find((status) => status.id === accepted.id)?.label,
|
||||||
|
result: supervisor.result(accepted.id).label,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accepted: label,
|
||||||
|
childRequest: label,
|
||||||
|
status: label,
|
||||||
|
list: label,
|
||||||
|
result: label,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.ok(inspectorLines.some((line) => line.includes(`completed 0s ${label} result: available`)), inspectorLines.join("\n"));
|
||||||
|
assert.doesNotMatch(accepted.label, /^ad-hoc sg-/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("milestone notifications use the stored label", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const accepted = supervisor.spawn({ prompt: "work", label: "Review migration" });
|
||||||
|
await sleep(0);
|
||||||
|
runner.starts[0].events.completed("done", "agent_settled");
|
||||||
|
|
||||||
|
assert.deepEqual(milestoneNotification(supervisor.status(accepted.id), "completed"), {
|
||||||
|
message: "Subagent Review migration completed",
|
||||||
|
level: "info",
|
||||||
|
});
|
||||||
|
assert.equal(milestoneNotification(supervisor.status(accepted.id), "running"), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shutdown clears recent terminal expiry timer", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
let changes = 0;
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp", {
|
||||||
|
recentTerminalTtlMs: 5,
|
||||||
|
onChange: () => {
|
||||||
|
changes += 1;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await spawnStarted(supervisor);
|
||||||
|
|
||||||
|
await supervisor.shutdown();
|
||||||
|
const afterShutdown = changes;
|
||||||
|
await sleep(15);
|
||||||
|
|
||||||
|
assert.equal(changes, afterShutdown);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("batch spawn returns explicit labels on accepted child requests and statuses while preserving failures", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
|
||||||
|
const result = supervisor.spawnBatch([
|
||||||
|
{ prompt: "one", label: "Review docs" },
|
||||||
|
{ prompt: "" },
|
||||||
|
{ prompt: "two", label: "Check tests" },
|
||||||
|
]);
|
||||||
|
await sleep(0);
|
||||||
|
|
||||||
|
assert.deepEqual(result.accepted.map((accepted) => accepted.label), ["Review docs", "Check tests"]);
|
||||||
|
assert.equal(result.failed.length, 1);
|
||||||
|
assert.equal(result.failed[0].index, 1);
|
||||||
|
assert.deepEqual(runner.starts.map((start) => start.request.label), ["Review docs", "Check tests"]);
|
||||||
|
assert.deepEqual(result.accepted.map((accepted) => supervisor.status(accepted.id).label), ["Review docs", "Check tests"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maxConcurrent preserves queued records", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp", { maxConcurrent: 1 });
|
||||||
|
|
||||||
|
const result = supervisor.spawnBatch([{ prompt: "one" }, { prompt: "two" }]);
|
||||||
|
await sleep(0);
|
||||||
|
|
||||||
|
assert.equal(result.accepted.length, 2);
|
||||||
|
assert.equal(runner.starts.length, 1);
|
||||||
|
assert.equal(supervisor.status(result.accepted[1].id).state, "queued");
|
||||||
|
|
||||||
|
runner.starts[0].events.completed("done", "agent_settled");
|
||||||
|
await sleep(0);
|
||||||
|
|
||||||
|
assert.equal(runner.starts.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clearTerminal returns only removed terminal ids", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const first = await spawnStarted(supervisor, "one");
|
||||||
|
const second = await spawnStarted(supervisor, "two");
|
||||||
|
const running = await spawnStarted(supervisor, "three");
|
||||||
|
|
||||||
|
runner.starts[0].events.completed("one done", "agent_settled");
|
||||||
|
runner.starts[1].events.completed("two done", "agent_settled");
|
||||||
|
|
||||||
|
assert.deepEqual(supervisor.clearTerminal(), [first.id, second.id]);
|
||||||
|
assert.throws(() => supervisor.status(first.id), /unknown subagent id/);
|
||||||
|
assert.throws(() => supervisor.status(second.id), /unknown subagent id/);
|
||||||
|
assert.equal(supervisor.status(running.id).state, "running");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("terminal records expire after ttl while active children remain", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp", { recentTerminalTtlMs: 5 });
|
||||||
|
const completed = await spawnStarted(supervisor, "one");
|
||||||
|
const failed = await spawnStarted(supervisor, "two");
|
||||||
|
const running = await spawnStarted(supervisor, "three");
|
||||||
|
|
||||||
|
runner.starts[0].events.completed("one done", "agent_settled");
|
||||||
|
runner.starts[1].events.failed("two failed");
|
||||||
|
|
||||||
|
assert.equal(supervisor.result(completed.id).result, "one done");
|
||||||
|
assert.equal(supervisor.result(failed.id).error, "two failed");
|
||||||
|
assert.equal(supervisor.status(running.id).state, "running");
|
||||||
|
|
||||||
|
await sleep(20);
|
||||||
|
|
||||||
|
const listedIds = supervisor.list().map((status) => status.id);
|
||||||
|
assert.equal(listedIds.includes(completed.id), false);
|
||||||
|
assert.equal(listedIds.includes(failed.id), false);
|
||||||
|
assert.equal(listedIds.includes(running.id), true);
|
||||||
|
assert.throws(() => supervisor.status(completed.id), /unknown subagent id/);
|
||||||
|
assert.throws(() => supervisor.status(failed.id), /unknown subagent id/);
|
||||||
|
assert.throws(() => supervisor.result(completed.id), /unknown subagent id/);
|
||||||
|
assert.throws(() => supervisor.result(failed.id), /unknown subagent id/);
|
||||||
|
assert.equal(supervisor.status(running.id).state, "running");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("zero recent terminal ttl does not hide terminal statuses", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp", { recentTerminalTtlMs: 0 });
|
||||||
|
const accepted = await spawnStarted(supervisor);
|
||||||
|
|
||||||
|
runner.starts[0].events.completed("done", "agent_settled");
|
||||||
|
|
||||||
|
assert.equal(supervisor.list().some((status) => status.id === accepted.id), true);
|
||||||
|
assert.equal(supervisor.result(accepted.id).result, "done");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wait blocks until multiple subagents are terminal", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const first = await spawnStarted(supervisor, "one");
|
||||||
|
const second = await spawnStarted(supervisor, "two");
|
||||||
|
|
||||||
|
const waiting = supervisor.wait([first.id, second.id], { timeoutMs: 100 });
|
||||||
|
runner.starts[0].events.completed("one done", "agent_settled");
|
||||||
|
await sleep(0);
|
||||||
|
|
||||||
|
assert.equal(await Promise.race([waiting.then(() => "done"), sleep(10).then(() => "pending")]), "pending");
|
||||||
|
|
||||||
|
runner.starts[1].events.failed("two failed");
|
||||||
|
const result = await waiting;
|
||||||
|
|
||||||
|
assert.equal(result.timedOut, false);
|
||||||
|
assert.equal(result.ready, true);
|
||||||
|
assert.deepEqual(result.ids, [first.id, second.id]);
|
||||||
|
assert.equal(result.pending.length, 0);
|
||||||
|
assert.deepEqual(result.results.map((item) => item.state), ["completed", "failed"]);
|
||||||
|
assert.equal(result.results[0].result, "one done");
|
||||||
|
assert.equal(result.results[1].error, "two failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wait returns pending statuses on timeout", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const first = await spawnStarted(supervisor, "one");
|
||||||
|
const second = await spawnStarted(supervisor, "two");
|
||||||
|
|
||||||
|
runner.starts[0].events.completed("one done", "agent_settled");
|
||||||
|
const result = await supervisor.wait([first.id, second.id], { timeoutMs: 5 });
|
||||||
|
|
||||||
|
assert.equal(result.timedOut, true);
|
||||||
|
assert.equal(result.ready, false);
|
||||||
|
assert.deepEqual(result.results.map((item) => item.state), ["completed", "running"]);
|
||||||
|
assert.deepEqual(result.pending.map((item) => item.id), [second.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wait any returns after the first terminal subagent", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const first = await spawnStarted(supervisor, "one");
|
||||||
|
const second = await spawnStarted(supervisor, "two");
|
||||||
|
|
||||||
|
const waiting = supervisor.wait([first.id, second.id], { mode: "any", timeoutMs: 100 });
|
||||||
|
runner.starts[1].events.completed("two done", "agent_settled");
|
||||||
|
const result = await waiting;
|
||||||
|
|
||||||
|
assert.equal(result.timedOut, false);
|
||||||
|
assert.equal(result.ready, true);
|
||||||
|
assert.deepEqual(result.results.map((item) => item.state), ["running", "completed"]);
|
||||||
|
assert.deepEqual(result.pending.map((item) => item.id), [first.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wait rejects unknown and empty id sets", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
|
||||||
|
await assert.rejects(() => supervisor.wait([]), /at least one subagent id is required/);
|
||||||
|
await assert.rejects(() => supervisor.wait(["missing"]), /unknown subagent id: missing/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wait abort rejects without cancelling child", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const accepted = await spawnStarted(supervisor, "one");
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const waiting = supervisor.wait([accepted.id], { signal: controller.signal });
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
await assert.rejects(waiting, /subagent wait aborted/);
|
||||||
|
assert.equal(runner.starts[0].handle.cancelCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wait follows queued subagents through queue start and completion", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp", { maxConcurrent: 1 });
|
||||||
|
const batch = supervisor.spawnBatch([{ prompt: "one" }, { prompt: "two" }]);
|
||||||
|
await sleep(0);
|
||||||
|
|
||||||
|
const waiting = supervisor.wait([batch.accepted[1].id], { timeoutMs: 100 });
|
||||||
|
assert.equal(await Promise.race([waiting.then(() => "done"), sleep(10).then(() => "pending")]), "pending");
|
||||||
|
|
||||||
|
runner.starts[0].events.completed("one done", "agent_settled");
|
||||||
|
await sleep(0);
|
||||||
|
runner.starts[1].events.completed("two done", "agent_settled");
|
||||||
|
const result = await waiting;
|
||||||
|
|
||||||
|
assert.equal(result.timedOut, false);
|
||||||
|
assert.equal(result.ready, true);
|
||||||
|
assert.deepEqual(result.results.map((item) => item.result), ["two done"]);
|
||||||
|
});
|
||||||
558
modules/agents/pi/extensions/subagents/supervisor.ts
Normal file
558
modules/agents/pi/extensions/subagents/supervisor.ts
Normal file
@@ -0,0 +1,558 @@
|
|||||||
|
import type {
|
||||||
|
ChildHandle,
|
||||||
|
ChildRecord,
|
||||||
|
ChildRunner,
|
||||||
|
ContextMode,
|
||||||
|
RunnerActivity,
|
||||||
|
RunnerEvents,
|
||||||
|
SpawnAccepted,
|
||||||
|
SpawnRequest,
|
||||||
|
SubagentResult,
|
||||||
|
SubagentStatus,
|
||||||
|
SubagentWaitMode,
|
||||||
|
SubagentWaitResult,
|
||||||
|
} from "./types.ts";
|
||||||
|
import { cloneResult, cloneStatus, isTerminalState, toAccepted } from "./status.ts";
|
||||||
|
|
||||||
|
interface RunningChild {
|
||||||
|
record: ChildRecord;
|
||||||
|
request: SpawnRequest;
|
||||||
|
handle?: ChildHandle;
|
||||||
|
startTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
runTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
expiryTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SupervisorOptions {
|
||||||
|
maxConcurrent?: number;
|
||||||
|
recentTerminalLimit?: number;
|
||||||
|
recentTerminalTtlMs?: number;
|
||||||
|
timeouts?: {
|
||||||
|
startMs?: number;
|
||||||
|
runMs?: number;
|
||||||
|
};
|
||||||
|
onMilestone?: (status: SubagentStatus, event: string) => void;
|
||||||
|
onChange?: (statuses: SubagentStatus[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchSpawnResult {
|
||||||
|
accepted: SpawnAccepted[];
|
||||||
|
failed: Array<{ index: number; error: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUTS = {
|
||||||
|
startMs: 30_000,
|
||||||
|
runMs: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_ACTIVITY_HISTORY = 100;
|
||||||
|
|
||||||
|
export class Supervisor {
|
||||||
|
private nextChild = 0;
|
||||||
|
private readonly children = new Map<string, RunningChild>();
|
||||||
|
private readonly queue: RunningChild[] = [];
|
||||||
|
private readonly waiters = new Set<() => void>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly runner: ChildRunner,
|
||||||
|
private readonly cwd: string,
|
||||||
|
private readonly options: SupervisorOptions = {},
|
||||||
|
) {}
|
||||||
|
|
||||||
|
spawn(request: SpawnRequest): SpawnAccepted {
|
||||||
|
return this.createChild(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnBatch(requests: SpawnRequest[]): BatchSpawnResult {
|
||||||
|
const accepted: SpawnAccepted[] = [];
|
||||||
|
const failed: Array<{ index: number; error: string }> = [];
|
||||||
|
requests.forEach((request, index) => {
|
||||||
|
try {
|
||||||
|
accepted.push(this.createChild(request));
|
||||||
|
} catch (error) {
|
||||||
|
failed.push({ index, error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { accepted, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): SubagentStatus[] {
|
||||||
|
const statuses = [...this.children.values()].map((child) => cloneStatus(child.record.status));
|
||||||
|
const active = statuses.filter((status) => !isTerminal(status.state));
|
||||||
|
const terminal = statuses
|
||||||
|
.filter((status) => isTerminal(status.state))
|
||||||
|
.sort((a, b) => Date.parse(b.completedAt ?? b.startedAt) - Date.parse(a.completedAt ?? a.startedAt));
|
||||||
|
return [...active, ...terminal];
|
||||||
|
}
|
||||||
|
|
||||||
|
status(id: string): SubagentStatus {
|
||||||
|
return cloneStatus(this.require(id).record.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
result(id: string): SubagentResult {
|
||||||
|
return cloneResult(this.require(id).record);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTerminal(ids?: string[]): string[] {
|
||||||
|
const selectedIds = ids ? [...new Set(ids.map((id) => id.trim()).filter(Boolean))] : undefined;
|
||||||
|
if (selectedIds) for (const id of selectedIds) this.require(id);
|
||||||
|
const cleared: string[] = [];
|
||||||
|
for (const [id, child] of this.children) {
|
||||||
|
if (selectedIds && !selectedIds.includes(id)) continue;
|
||||||
|
if (!isTerminal(child.record.status.state)) continue;
|
||||||
|
this.clearTimer(child, "expiryTimer");
|
||||||
|
cleared.push(id);
|
||||||
|
this.children.delete(id);
|
||||||
|
}
|
||||||
|
if (cleared.length > 0) this.emitChange();
|
||||||
|
return cleared;
|
||||||
|
}
|
||||||
|
|
||||||
|
async wait(
|
||||||
|
ids: string[],
|
||||||
|
options: { timeoutMs?: number; signal?: AbortSignal; mode?: SubagentWaitMode } = {},
|
||||||
|
): Promise<SubagentWaitResult> {
|
||||||
|
const uniqueIds = [...new Set(ids.map((id) => id.trim()).filter(Boolean))];
|
||||||
|
if (uniqueIds.length === 0) throw new Error("at least one subagent id is required");
|
||||||
|
for (const id of uniqueIds) this.require(id);
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const mode = options.mode ?? "all";
|
||||||
|
if (mode !== "all" && mode !== "any") throw new Error(`unknown wait mode: ${mode}`);
|
||||||
|
const deadline = options.timeoutMs && options.timeoutMs > 0 ? startedAt + options.timeoutMs : undefined;
|
||||||
|
let timedOut = false;
|
||||||
|
|
||||||
|
while (!this.waitReady(uniqueIds, mode)) {
|
||||||
|
if (options.signal?.aborted) throw new Error("subagent wait aborted");
|
||||||
|
const remainingMs = deadline === undefined ? undefined : deadline - Date.now();
|
||||||
|
if (remainingMs !== undefined && remainingMs <= 0) {
|
||||||
|
timedOut = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await this.nextChange(remainingMs, options.signal).catch((error) => {
|
||||||
|
if (error instanceof Error && error.message === "subagent wait timed out") timedOut = true;
|
||||||
|
else throw error;
|
||||||
|
});
|
||||||
|
if (timedOut) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = uniqueIds.map((id) => this.result(id));
|
||||||
|
const pending = uniqueIds
|
||||||
|
.map((id) => this.status(id))
|
||||||
|
.filter((status) => !isTerminal(status.state));
|
||||||
|
return { ids: uniqueIds, mode, ready: this.waitReady(uniqueIds, mode), results, pending, timedOut, elapsedMs: Date.now() - startedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(id: string): Promise<SubagentStatus> {
|
||||||
|
const child = this.require(id);
|
||||||
|
if (isTerminal(child.record.status.state)) return cloneStatus(child.record.status);
|
||||||
|
await child.handle?.cancel();
|
||||||
|
this.completeWithoutResult(child, "cancelled", "cancelled");
|
||||||
|
this.pumpQueue();
|
||||||
|
return cloneStatus(child.record.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
async shutdown(): Promise<void> {
|
||||||
|
await Promise.allSettled(
|
||||||
|
[...this.children.values()].map(async (child) => {
|
||||||
|
if (!isTerminal(child.record.status.state)) {
|
||||||
|
await child.handle?.cancel();
|
||||||
|
this.completeWithoutResult(child, "cancelled", "shutdown");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const child of this.children.values()) this.clearTimer(child, "expiryTimer");
|
||||||
|
}
|
||||||
|
|
||||||
|
private createChild(request: SpawnRequest): SpawnAccepted {
|
||||||
|
const prompt = typeof request.prompt === "string" ? request.prompt.trim() : "";
|
||||||
|
if (!prompt) throw new Error("prompt is required");
|
||||||
|
|
||||||
|
const id = this.allocateId();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const status: SubagentStatus = {
|
||||||
|
id,
|
||||||
|
label: deriveLabel(request, id),
|
||||||
|
agent: request.agent,
|
||||||
|
adHoc: !request.agent,
|
||||||
|
context: this.resolveContext(request.context),
|
||||||
|
state: "queued",
|
||||||
|
cwd: this.cwd,
|
||||||
|
model: request.model,
|
||||||
|
thinking: request.thinking,
|
||||||
|
tools: request.tools ?? "read-only",
|
||||||
|
startedAt: now,
|
||||||
|
elapsedMs: 0,
|
||||||
|
lastEvent: "queued",
|
||||||
|
lastEventAt: now,
|
||||||
|
currentActivity: { type: "queued", summary: "queued", at: now },
|
||||||
|
activityHistory: [{ type: "queued", summary: "queued", at: now }],
|
||||||
|
resultAvailable: false,
|
||||||
|
};
|
||||||
|
const child: RunningChild = { record: { status, activityEvents: [{ type: "queued", summary: "queued", at: now }] }, request: { ...request, prompt, label: status.label, context: status.context, tools: status.tools } };
|
||||||
|
this.children.set(id, child);
|
||||||
|
this.emitMilestone(child, "accepted");
|
||||||
|
this.queue.push(child);
|
||||||
|
this.pumpQueue();
|
||||||
|
return toAccepted(cloneStatus(status));
|
||||||
|
}
|
||||||
|
|
||||||
|
private pumpQueue() {
|
||||||
|
while (this.runningCount() < this.maxConcurrent()) {
|
||||||
|
const child = this.queue.shift();
|
||||||
|
if (!child) break;
|
||||||
|
if (isTerminal(child.record.status.state)) continue;
|
||||||
|
this.start(child);
|
||||||
|
}
|
||||||
|
this.emitChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
private start(child: RunningChild) {
|
||||||
|
this.setState(child.record.status, "starting", "starting");
|
||||||
|
this.armStartTimer(child);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (isTerminal(child.record.status.state)) return;
|
||||||
|
void this.runner
|
||||||
|
.start(child.record.status.id, child.request, this.cwd, this.eventsFor(child.record))
|
||||||
|
.then((handle) => {
|
||||||
|
child.handle = handle;
|
||||||
|
if (isTerminal(child.record.status.state)) void handle.cancel();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.fail(child.record, error instanceof Error ? error.message : String(error));
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private eventsFor(record: ChildRecord): RunnerEvents {
|
||||||
|
return {
|
||||||
|
accepted: (childSession) => {
|
||||||
|
const child = this.findChild(record);
|
||||||
|
if (child) {
|
||||||
|
this.clearTimer(child, "startTimer");
|
||||||
|
this.armRunTimer(child);
|
||||||
|
}
|
||||||
|
if (childSession) record.status.childSession = childSession;
|
||||||
|
this.setState(record.status, "running", "prompt accepted");
|
||||||
|
},
|
||||||
|
running: (event) => {
|
||||||
|
if (!isTerminal(record.status.state)) this.setState(record.status, "running", event);
|
||||||
|
},
|
||||||
|
settling: () => {
|
||||||
|
if (!isTerminal(record.status.state)) this.setState(record.status, "settling", "agent_settled");
|
||||||
|
},
|
||||||
|
completed: (result, stopReason) => {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const child = this.findChild(record);
|
||||||
|
if (child) this.clearTimers(child);
|
||||||
|
record.result = result;
|
||||||
|
record.status.state = "completed";
|
||||||
|
record.status.completedAt = now;
|
||||||
|
record.status.lastEvent = "completed";
|
||||||
|
record.status.lastEventAt = now;
|
||||||
|
this.recordActivity(record, "completed", now);
|
||||||
|
record.status.stopReason = stopReason;
|
||||||
|
record.status.resultAvailable = true;
|
||||||
|
if (child) {
|
||||||
|
this.armTerminalExpiry(child);
|
||||||
|
this.emitMilestone(child, "completed");
|
||||||
|
}
|
||||||
|
this.pumpQueue();
|
||||||
|
},
|
||||||
|
failed: (error) => this.fail(record, error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private fail(record: ChildRecord, error: string) {
|
||||||
|
if (isTerminal(record.status.state)) return;
|
||||||
|
const child = this.findChild(record);
|
||||||
|
if (child) this.clearTimers(child);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
record.status.state = "failed";
|
||||||
|
record.status.completedAt = now;
|
||||||
|
record.status.lastEvent = "failed";
|
||||||
|
record.status.lastEventAt = now;
|
||||||
|
this.recordActivity(record, "failed", now);
|
||||||
|
record.status.error = error;
|
||||||
|
record.status.stopReason = "failed";
|
||||||
|
if (child) {
|
||||||
|
this.armTerminalExpiry(child);
|
||||||
|
this.emitMilestone(child, "failed");
|
||||||
|
}
|
||||||
|
this.pumpQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private completeWithoutResult(child: RunningChild, state: "cancelled" | "timed_out", reason: string) {
|
||||||
|
if (isTerminal(child.record.status.state)) return;
|
||||||
|
this.clearTimers(child);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
child.record.status.state = state;
|
||||||
|
child.record.status.completedAt = now;
|
||||||
|
child.record.status.lastEvent = state;
|
||||||
|
child.record.status.lastEventAt = now;
|
||||||
|
this.recordActivity(child.record, state, now);
|
||||||
|
child.record.status.stopReason = reason;
|
||||||
|
this.armTerminalExpiry(child);
|
||||||
|
this.emitMilestone(child, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private armStartTimer(child: RunningChild) {
|
||||||
|
const timeout = this.options.timeouts?.startMs ?? DEFAULT_TIMEOUTS.startMs;
|
||||||
|
if (timeout <= 0) return;
|
||||||
|
child.startTimer = setTimeout(() => {
|
||||||
|
this.timeout(child, "start_timeout");
|
||||||
|
}, timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
private armRunTimer(child: RunningChild) {
|
||||||
|
const timeout = this.options.timeouts?.runMs ?? DEFAULT_TIMEOUTS.runMs;
|
||||||
|
if (timeout <= 0) return;
|
||||||
|
child.runTimer = setTimeout(() => {
|
||||||
|
this.timeout(child, "run_timeout");
|
||||||
|
}, timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
private timeout(child: RunningChild, reason: string) {
|
||||||
|
if (isTerminal(child.record.status.state)) return;
|
||||||
|
void child.handle?.cancel();
|
||||||
|
this.completeWithoutResult(child, "timed_out", reason);
|
||||||
|
this.pumpQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private armTerminalExpiry(child: RunningChild) {
|
||||||
|
const ttl = this.options.recentTerminalTtlMs;
|
||||||
|
if (ttl === undefined || ttl <= 0) return;
|
||||||
|
this.clearTimer(child, "expiryTimer");
|
||||||
|
child.expiryTimer = setTimeout(() => {
|
||||||
|
child.expiryTimer = undefined;
|
||||||
|
const id = child.record.status.id;
|
||||||
|
if (this.children.get(id) !== child || !isTerminal(child.record.status.state)) return;
|
||||||
|
this.children.delete(id);
|
||||||
|
this.emitChange();
|
||||||
|
}, ttl);
|
||||||
|
child.expiryTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTimers(child: RunningChild) {
|
||||||
|
this.clearTimer(child, "startTimer");
|
||||||
|
this.clearTimer(child, "runTimer");
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTimer(child: RunningChild, key: "startTimer" | "runTimer" | "expiryTimer") {
|
||||||
|
const timer = child[key];
|
||||||
|
if (!timer) return;
|
||||||
|
clearTimeout(timer);
|
||||||
|
child[key] = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private findChild(record: ChildRecord): RunningChild | undefined {
|
||||||
|
return [...this.children.values()].find((child) => child.record === record);
|
||||||
|
}
|
||||||
|
|
||||||
|
activity(id: string) {
|
||||||
|
return this.require(id).record.activityEvents.map((event) => ({ ...event }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private setState(status: SubagentStatus, state: SubagentStatus["state"], event: RunnerActivity) {
|
||||||
|
if (isTerminal(status.state)) return;
|
||||||
|
const record = this.require(status.id).record;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const activity = this.recordActivity(record, event, now);
|
||||||
|
status.state = state;
|
||||||
|
status.lastEvent = activity.type;
|
||||||
|
status.lastEventAt = now;
|
||||||
|
this.emitChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordActivity(record: ChildRecord, event: RunnerActivity, at: string) {
|
||||||
|
const activity = normalizeActivity(event, at);
|
||||||
|
record.activityEvents.push(activity);
|
||||||
|
const summary = summarizeActivity(activity);
|
||||||
|
record.status.currentActivity = summary;
|
||||||
|
record.status.activityHistory.push(summary);
|
||||||
|
if (record.status.activityHistory.length > MAX_ACTIVITY_HISTORY) {
|
||||||
|
record.status.activityHistory.splice(0, record.status.activityHistory.length - MAX_ACTIVITY_HISTORY);
|
||||||
|
}
|
||||||
|
return activity;
|
||||||
|
}
|
||||||
|
|
||||||
|
private require(id: string): RunningChild {
|
||||||
|
const child = this.children.get(id);
|
||||||
|
if (!child) throw new Error(`unknown subagent id: ${id}`);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveContext(context: ContextMode | undefined): ContextMode {
|
||||||
|
if (context === undefined) return "independent";
|
||||||
|
if (context !== "independent" && context !== "fork") throw new Error(`unknown context: ${context}`);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private maxConcurrent(): number {
|
||||||
|
return Math.max(1, this.options.maxConcurrent ?? 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
private runningCount(): number {
|
||||||
|
return [...this.children.values()].filter((child) => ["starting", "running", "settling"].includes(child.record.status.state)).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitMilestone(child: RunningChild, event: string) {
|
||||||
|
this.options.onMilestone?.(cloneStatus(child.record.status), event);
|
||||||
|
this.emitChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitChange() {
|
||||||
|
this.options.onChange?.(this.list());
|
||||||
|
for (const waiter of this.waiters) waiter();
|
||||||
|
}
|
||||||
|
|
||||||
|
private waitReady(ids: string[], mode: SubagentWaitMode): boolean {
|
||||||
|
const terminal = (id: string) => isTerminal(this.require(id).record.status.state);
|
||||||
|
return mode === "all" ? ids.every(terminal) : ids.some(terminal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private nextChange(timeoutMs: number | undefined, signal: AbortSignal | undefined): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const cleanup = () => {
|
||||||
|
this.waiters.delete(resolveOnce);
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
signal?.removeEventListener("abort", abort);
|
||||||
|
};
|
||||||
|
const resolveOnce = () => {
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const abort = () => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error("subagent wait aborted"));
|
||||||
|
};
|
||||||
|
this.waiters.add(resolveOnce);
|
||||||
|
signal?.addEventListener("abort", abort, { once: true });
|
||||||
|
if (timeoutMs !== undefined) {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error("subagent wait timed out"));
|
||||||
|
}, timeoutMs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private allocateId(): string {
|
||||||
|
this.nextChild += 1;
|
||||||
|
return `sg-${Date.now().toString(36)}-${this.nextChild.toString(36)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveLabel(request: SpawnRequest, id: string): string {
|
||||||
|
const explicit = normalizeLabel(request.label);
|
||||||
|
if (explicit) return explicit;
|
||||||
|
const agent = normalizeLabel(request.agent);
|
||||||
|
if (agent) return agent;
|
||||||
|
return promptLabel(request.prompt) ?? `ad-hoc ${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptLabel(prompt: string): string | undefined {
|
||||||
|
const normalized = normalizeLabel(prompt);
|
||||||
|
if (!normalized) return undefined;
|
||||||
|
return truncateLabel(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLabel(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== "string") return undefined;
|
||||||
|
const normalized = value.replace(/\s+/gu, " ").trim();
|
||||||
|
return normalized || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateLabel(label: string): string {
|
||||||
|
const maxLength = 80;
|
||||||
|
if (label.length <= maxLength) return label;
|
||||||
|
return `${label.slice(0, maxLength - 1).trimEnd()}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTerminal(state: SubagentStatus["state"]): boolean {
|
||||||
|
return isTerminalState(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeActivity(event: RunnerActivity, at: string) {
|
||||||
|
if (typeof event === "string") return { type: event, summary: event, at };
|
||||||
|
const type = typeof event.type === "string" ? event.type : "activity";
|
||||||
|
const role = typeof event.role === "string" ? event.role : undefined;
|
||||||
|
const tool = toolFromActivity(event);
|
||||||
|
const phase = typeof event.phase === "string" ? event.phase : phaseFromType(type, event);
|
||||||
|
const text = textFromActivity(event);
|
||||||
|
const input = inputFromActivity(event);
|
||||||
|
const output = "output" in event ? event.output : "result" in event ? event.result : "partialResult" in event ? event.partialResult : undefined;
|
||||||
|
const error = typeof event.error === "string" ? event.error : undefined;
|
||||||
|
return { type, summary: summaryFor({ type, role, tool, phase, input, output, error }), at, role, tool, phase, text, input, output, error, payload: { ...event } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeActivity(activity: ReturnType<typeof normalizeActivity>) {
|
||||||
|
const { type, summary, at, role, tool, phase } = activity;
|
||||||
|
return { type, summary, at, role, tool, phase };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolFromActivity(event: Record<string, unknown>): string | undefined {
|
||||||
|
for (const key of ["tool", "toolName", "name"]) {
|
||||||
|
const value = event[key];
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function phaseFromType(type: string, event: Record<string, unknown>): string | undefined {
|
||||||
|
const assistantEvent = event.assistantMessageEvent;
|
||||||
|
if (assistantEvent && typeof assistantEvent === "object" && !Array.isArray(assistantEvent)) {
|
||||||
|
const assistantType = (assistantEvent as { type?: unknown }).type;
|
||||||
|
if (typeof assistantType === "string") return assistantType;
|
||||||
|
}
|
||||||
|
if (type.endsWith("_start")) return "started";
|
||||||
|
if (type.endsWith("_started")) return "started";
|
||||||
|
if (type.endsWith("_update")) return "update";
|
||||||
|
if (type.endsWith("_delta")) return "delta";
|
||||||
|
if (type.endsWith("_end")) return "completed";
|
||||||
|
if (type.endsWith("_completed")) return "completed";
|
||||||
|
if (type.endsWith("_failed")) return "failed";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textFromActivity(event: Record<string, unknown>): string | undefined {
|
||||||
|
for (const key of ["text", "body", "content", "delta"]) {
|
||||||
|
const value = event[key];
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
}
|
||||||
|
const assistantEvent = event.assistantMessageEvent;
|
||||||
|
if (assistantEvent && typeof assistantEvent === "object" && !Array.isArray(assistantEvent)) {
|
||||||
|
for (const key of ["delta", "content"]) {
|
||||||
|
const value = (assistantEvent as Record<string, unknown>)[key];
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputFromActivity(event: Record<string, unknown>): unknown {
|
||||||
|
if ("input" in event) return event.input;
|
||||||
|
if ("args" in event) return event.args;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryFor(activity: { type: string; role?: string; tool?: string; phase?: string; input?: unknown; output?: unknown; error?: string }): string {
|
||||||
|
if (activity.error) return `${activity.tool ?? activity.type} failed: ${activity.error}`;
|
||||||
|
if (activity.tool) return `${activity.tool}${inputHint(activity.input)}`;
|
||||||
|
if (activity.type.startsWith("message")) return `${activity.role ?? "assistant"} message${activity.phase ? ` ${activity.phase}` : ""}`;
|
||||||
|
return activity.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputHint(input: unknown): string {
|
||||||
|
if (!input || typeof input !== "object" || Array.isArray(input)) return "";
|
||||||
|
const path = (input as { path?: unknown }).path;
|
||||||
|
if (typeof path === "string" && path.trim()) return ` ${path.trim()}`;
|
||||||
|
const command = (input as { command?: unknown }).command;
|
||||||
|
if (typeof command === "string" && command.trim()) return ` ${truncateActivityHint(command.trim())}`;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateActivityHint(value: string): string {
|
||||||
|
return value.length <= 80 ? value : `${value.slice(0, 79).trimEnd()}…`;
|
||||||
|
}
|
||||||
123
modules/agents/pi/extensions/subagents/types.ts
Normal file
123
modules/agents/pi/extensions/subagents/types.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
export type ContextMode = "independent" | "fork";
|
||||||
|
|
||||||
|
export const SUBAGENT_STATES = ["queued", "starting", "running", "settling", "completed", "failed", "cancelled", "timed_out", "orphaned"] as const;
|
||||||
|
export const SUBAGENT_TERMINAL_STATES = ["completed", "failed", "cancelled", "timed_out", "orphaned"] as const;
|
||||||
|
|
||||||
|
export type SubagentState = (typeof SUBAGENT_STATES)[number];
|
||||||
|
|
||||||
|
export interface ToolProfile {
|
||||||
|
activeTools: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpawnRequest {
|
||||||
|
prompt: string;
|
||||||
|
label?: string;
|
||||||
|
context?: ContextMode;
|
||||||
|
agent?: string;
|
||||||
|
model?: string;
|
||||||
|
thinking?: string;
|
||||||
|
tools?: string;
|
||||||
|
toolProfile?: ToolProfile;
|
||||||
|
agentBody?: string;
|
||||||
|
parentSessionFile?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpawnAccepted {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
context: ContextMode;
|
||||||
|
tools: string;
|
||||||
|
state: SubagentState;
|
||||||
|
hint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubagentActivitySummary {
|
||||||
|
type: string;
|
||||||
|
summary: string;
|
||||||
|
at: string;
|
||||||
|
role?: string;
|
||||||
|
tool?: string;
|
||||||
|
phase?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubagentActivityEvent extends SubagentActivitySummary {
|
||||||
|
text?: string;
|
||||||
|
input?: unknown;
|
||||||
|
output?: unknown;
|
||||||
|
error?: string;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubagentCurrentActivity extends SubagentActivitySummary {}
|
||||||
|
|
||||||
|
export type RunnerActivity = string | Record<string, unknown>;
|
||||||
|
|
||||||
|
export interface SubagentStatus {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
agent?: string;
|
||||||
|
adHoc: boolean;
|
||||||
|
context: ContextMode;
|
||||||
|
state: SubagentState;
|
||||||
|
cwd: string;
|
||||||
|
model?: string;
|
||||||
|
thinking?: string;
|
||||||
|
tools: string;
|
||||||
|
startedAt: string;
|
||||||
|
completedAt?: string;
|
||||||
|
elapsedMs: number;
|
||||||
|
lastEvent?: string;
|
||||||
|
lastEventAt?: string;
|
||||||
|
currentActivity?: SubagentCurrentActivity;
|
||||||
|
activityHistory: SubagentActivitySummary[];
|
||||||
|
stopReason?: string;
|
||||||
|
resultAvailable: boolean;
|
||||||
|
childSession?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubagentResult {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
state: SubagentState;
|
||||||
|
running: boolean;
|
||||||
|
resultAvailable: boolean;
|
||||||
|
result?: string;
|
||||||
|
error?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
elapsedMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubagentWaitMode = "all" | "any";
|
||||||
|
|
||||||
|
export interface SubagentWaitResult {
|
||||||
|
ids: string[];
|
||||||
|
mode: SubagentWaitMode;
|
||||||
|
ready: boolean;
|
||||||
|
results: SubagentResult[];
|
||||||
|
pending: SubagentStatus[];
|
||||||
|
timedOut: boolean;
|
||||||
|
elapsedMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChildRecord {
|
||||||
|
status: SubagentStatus;
|
||||||
|
activityEvents: SubagentActivityEvent[];
|
||||||
|
result?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunnerEvents {
|
||||||
|
accepted(childSession?: string): void;
|
||||||
|
running(event: RunnerActivity): void;
|
||||||
|
settling(): void;
|
||||||
|
completed(result: string, stopReason?: string): void;
|
||||||
|
failed(error: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChildHandle {
|
||||||
|
cancel(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChildRunner {
|
||||||
|
start(id: string, request: SpawnRequest, cwd: string, events: RunnerEvents): Promise<ChildHandle>;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user