Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
711dc46e5e | ||
|
|
039598855c | ||
|
|
d45ba712ce | ||
|
|
4d658004ae | ||
|
|
3329ff3088 | ||
|
|
2ebaee8d03 | ||
|
|
e30951ad72 | ||
|
|
e3cdd23260 |
+54
-8
@@ -27,6 +27,14 @@ and climate/sustainability tech.
|
|||||||
|
|
||||||
These rules apply to every task across every project, regardless of harness.
|
These rules apply to every task across every project, regardless of harness.
|
||||||
|
|
||||||
|
0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line:
|
||||||
|
- **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours.
|
||||||
|
- **Load the relevant skill** — see trigger table in *Engineering Skills* below.
|
||||||
|
- **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test.
|
||||||
|
- **State the observable success criterion** — what specific behavior, output, or passing test proves this is done?
|
||||||
|
|
||||||
|
**TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it.
|
||||||
|
|
||||||
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
||||||
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
||||||
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||||
@@ -49,6 +57,22 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
PR flow only when a human reviewer outside the project is required. Document
|
PR flow only when a human reviewer outside the project is required. Document
|
||||||
the reason in PROJECT.md.
|
the reason in PROJECT.md.
|
||||||
|
|
||||||
|
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
||||||
|
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||||
|
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
|
||||||
|
new/changed ADR, patch for a fix; docs in the same commit). Check the repo's
|
||||||
|
actual last tag — stated versions in docs drift stale.
|
||||||
|
- **Push** main and the tag (CI is the gate).
|
||||||
|
- **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) —
|
||||||
|
the reusable patterns and the footguns that would bite anyone again, never
|
||||||
|
project status. See *Knowledge base — when to write* below.
|
||||||
|
- **File discovered-but-deferred work as tracker issues** on the project's own
|
||||||
|
repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let
|
||||||
|
"out of scope, recorded" rot in a commit message; make it a ticket with a
|
||||||
|
source pointer.
|
||||||
|
- Surface the brain entries and issue numbers in the closing summary so the
|
||||||
|
trail is auditable.
|
||||||
|
|
||||||
## Default stack
|
## Default stack
|
||||||
|
|
||||||
| Layer | Default | Fallback | Last resort |
|
| Layer | Default | Fallback | Last resort |
|
||||||
@@ -78,6 +102,26 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
|
|||||||
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||||
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
|
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
|
||||||
|
|
||||||
|
## Secret handling (every harness, every command)
|
||||||
|
|
||||||
|
Tool output is persisted: terminal → `~/.claude/projects` transcripts →
|
||||||
|
claudewatcher → brain/wiki → gitea history. A secret printed once is
|
||||||
|
searchable forever, and clearing it means rotating the key. So:
|
||||||
|
|
||||||
|
1. **Never print, echo, log, or transform a secret to inspect it.** No
|
||||||
|
`base64`/`xxd`/`cat` of a key, and never pipe a secret through a transform
|
||||||
|
to defeat `op run`'s output masking (it masks raw values; base64 hides them
|
||||||
|
from the mask — that exact trick leaked a key on 2026-06-11).
|
||||||
|
2. **Secrets stay in the subprocess.** Reference them only as env vars consumed
|
||||||
|
*inside* `op run --env-file ~/.op-env -- <cmd>`. Never place a literal secret
|
||||||
|
in a command's argv (it lands in the tool call and the transcript).
|
||||||
|
3. **Existence check without revealing the value:** `[ -n "$X" ] && echo set` —
|
||||||
|
never `${X:-...}` (returns the value when set) and never echo a substring of it.
|
||||||
|
4. **Cross-host secrets:** run the secret-consuming command on the host that has
|
||||||
|
the secret; do not forward a raw key over ssh argv/stdout.
|
||||||
|
5. If a secret does leak into output, say so immediately and flag it for rotation —
|
||||||
|
don't bury it.
|
||||||
|
|
||||||
## Infrastructure
|
## Infrastructure
|
||||||
|
|
||||||
Three machines on Tailscale:
|
Three machines on Tailscale:
|
||||||
@@ -157,7 +201,7 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
|||||||
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||||
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||||
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
||||||
| **Browser / human inspection** | `https://gitea.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||||
|
|
||||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||||
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
||||||
@@ -219,15 +263,17 @@ unconditionally on every host, every harness.
|
|||||||
|
|
||||||
## Engineering Skills
|
## Engineering Skills
|
||||||
|
|
||||||
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
|
Shared engineering skills are available in `~/dev/.skills/`. Load at task start — not "on demand" but on schedule, before writing code. See `~/dev/.skills/SKILLS_INDEX.md` for the full list.
|
||||||
|
|
||||||
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
**Skill trigger table — load before starting, not after getting stuck:**
|
||||||
|
|
||||||
Key skills:
|
| Task type | Load |
|
||||||
- **TDD**: always write tests first — load `tdd` skill
|
|-----------|------|
|
||||||
- **Code Review**: load `code-review` skill before any review
|
| Any feature or bug fix | `tdd` |
|
||||||
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
| Refactor or design | `clean-code` or `solid` |
|
||||||
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
| Debug | `problem-analysis` |
|
||||||
|
| Review code or PRs | `code-review` |
|
||||||
|
| Frame a problem before coding | `problem-analysis` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-4
@@ -16,10 +16,7 @@
|
|||||||
},
|
},
|
||||||
"infra": {
|
"infra": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "https://infra-mcp.d-ma.be/mcp",
|
"url": "https://infra-mcp.d-ma.be/mcp"
|
||||||
"headers": {
|
|
||||||
"Authorization": "Bearer ${INFRA_MCP_TOKEN}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ and climate/sustainability tech.
|
|||||||
|
|
||||||
These rules apply to every task across every project, regardless of harness.
|
These rules apply to every task across every project, regardless of harness.
|
||||||
|
|
||||||
|
0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line:
|
||||||
|
- **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours.
|
||||||
|
- **Load the relevant skill** — see trigger table in *Engineering Skills* below.
|
||||||
|
- **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test.
|
||||||
|
- **State the observable success criterion** — what specific behavior, output, or passing test proves this is done?
|
||||||
|
|
||||||
|
**TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it.
|
||||||
|
|
||||||
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
||||||
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
||||||
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||||
@@ -54,6 +62,22 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
PR flow only when a human reviewer outside the project is required. Document
|
PR flow only when a human reviewer outside the project is required. Document
|
||||||
the reason in PROJECT.md.
|
the reason in PROJECT.md.
|
||||||
|
|
||||||
|
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
||||||
|
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||||
|
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
|
||||||
|
new/changed ADR, patch for a fix; docs in the same commit). Check the repo's
|
||||||
|
actual last tag — stated versions in docs drift stale.
|
||||||
|
- **Push** main and the tag (CI is the gate).
|
||||||
|
- **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) —
|
||||||
|
the reusable patterns and the footguns that would bite anyone again, never
|
||||||
|
project status. See *Knowledge base — when to write* below.
|
||||||
|
- **File discovered-but-deferred work as tracker issues** on the project's own
|
||||||
|
repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let
|
||||||
|
"out of scope, recorded" rot in a commit message; make it a ticket with a
|
||||||
|
source pointer.
|
||||||
|
- Surface the brain entries and issue numbers in the closing summary so the
|
||||||
|
trail is auditable.
|
||||||
|
|
||||||
## Default stack
|
## Default stack
|
||||||
|
|
||||||
| Layer | Default | Fallback | Last resort |
|
| Layer | Default | Fallback | Last resort |
|
||||||
@@ -83,6 +107,26 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
|
|||||||
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||||
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
|
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
|
||||||
|
|
||||||
|
## Secret handling (every harness, every command)
|
||||||
|
|
||||||
|
Tool output is persisted: terminal → `~/.claude/projects` transcripts →
|
||||||
|
claudewatcher → brain/wiki → gitea history. A secret printed once is
|
||||||
|
searchable forever, and clearing it means rotating the key. So:
|
||||||
|
|
||||||
|
1. **Never print, echo, log, or transform a secret to inspect it.** No
|
||||||
|
`base64`/`xxd`/`cat` of a key, and never pipe a secret through a transform
|
||||||
|
to defeat `op run`'s output masking (it masks raw values; base64 hides them
|
||||||
|
from the mask — that exact trick leaked a key on 2026-06-11).
|
||||||
|
2. **Secrets stay in the subprocess.** Reference them only as env vars consumed
|
||||||
|
*inside* `op run --env-file ~/.op-env -- <cmd>`. Never place a literal secret
|
||||||
|
in a command's argv (it lands in the tool call and the transcript).
|
||||||
|
3. **Existence check without revealing the value:** `[ -n "$X" ] && echo set` —
|
||||||
|
never `${X:-...}` (returns the value when set) and never echo a substring of it.
|
||||||
|
4. **Cross-host secrets:** run the secret-consuming command on the host that has
|
||||||
|
the secret; do not forward a raw key over ssh argv/stdout.
|
||||||
|
5. If a secret does leak into output, say so immediately and flag it for rotation —
|
||||||
|
don't bury it.
|
||||||
|
|
||||||
## Infrastructure
|
## Infrastructure
|
||||||
|
|
||||||
Three machines on Tailscale:
|
Three machines on Tailscale:
|
||||||
@@ -162,7 +206,7 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
|||||||
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||||
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||||
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
||||||
| **Browser / human inspection** | `https://gitea.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||||
|
|
||||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||||
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
||||||
@@ -224,15 +268,17 @@ unconditionally on every host, every harness.
|
|||||||
|
|
||||||
## Engineering Skills
|
## Engineering Skills
|
||||||
|
|
||||||
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
|
Shared engineering skills are available in `~/dev/.skills/`. Load at task start — not "on demand" but on schedule, before writing code. See `~/dev/.skills/SKILLS_INDEX.md` for the full list.
|
||||||
|
|
||||||
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
**Skill trigger table — load before starting, not after getting stuck:**
|
||||||
|
|
||||||
Key skills:
|
| Task type | Load |
|
||||||
- **TDD**: always write tests first — load `tdd` skill
|
|-----------|------|
|
||||||
- **Code Review**: load `code-review` skill before any review
|
| Any feature or bug fix | `tdd` |
|
||||||
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
| Refactor or design | `clean-code` or `solid` |
|
||||||
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
| Debug | `problem-analysis` |
|
||||||
|
| Review code or PRs | `code-review` |
|
||||||
|
| Frame a problem before coding | `problem-analysis` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+54
-8
@@ -30,6 +30,14 @@ and climate/sustainability tech.
|
|||||||
|
|
||||||
These rules apply to every task across every project, regardless of harness.
|
These rules apply to every task across every project, regardless of harness.
|
||||||
|
|
||||||
|
0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line:
|
||||||
|
- **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours.
|
||||||
|
- **Load the relevant skill** — see trigger table in *Engineering Skills* below.
|
||||||
|
- **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test.
|
||||||
|
- **State the observable success criterion** — what specific behavior, output, or passing test proves this is done?
|
||||||
|
|
||||||
|
**TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it.
|
||||||
|
|
||||||
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
||||||
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
||||||
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||||
@@ -52,6 +60,22 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
PR flow only when a human reviewer outside the project is required. Document
|
PR flow only when a human reviewer outside the project is required. Document
|
||||||
the reason in PROJECT.md.
|
the reason in PROJECT.md.
|
||||||
|
|
||||||
|
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
||||||
|
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||||
|
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
|
||||||
|
new/changed ADR, patch for a fix; docs in the same commit). Check the repo's
|
||||||
|
actual last tag — stated versions in docs drift stale.
|
||||||
|
- **Push** main and the tag (CI is the gate).
|
||||||
|
- **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) —
|
||||||
|
the reusable patterns and the footguns that would bite anyone again, never
|
||||||
|
project status. See *Knowledge base — when to write* below.
|
||||||
|
- **File discovered-but-deferred work as tracker issues** on the project's own
|
||||||
|
repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let
|
||||||
|
"out of scope, recorded" rot in a commit message; make it a ticket with a
|
||||||
|
source pointer.
|
||||||
|
- Surface the brain entries and issue numbers in the closing summary so the
|
||||||
|
trail is auditable.
|
||||||
|
|
||||||
## Default stack
|
## Default stack
|
||||||
|
|
||||||
| Layer | Default | Fallback | Last resort |
|
| Layer | Default | Fallback | Last resort |
|
||||||
@@ -81,6 +105,26 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
|
|||||||
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||||
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
|
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
|
||||||
|
|
||||||
|
## Secret handling (every harness, every command)
|
||||||
|
|
||||||
|
Tool output is persisted: terminal → `~/.claude/projects` transcripts →
|
||||||
|
claudewatcher → brain/wiki → gitea history. A secret printed once is
|
||||||
|
searchable forever, and clearing it means rotating the key. So:
|
||||||
|
|
||||||
|
1. **Never print, echo, log, or transform a secret to inspect it.** No
|
||||||
|
`base64`/`xxd`/`cat` of a key, and never pipe a secret through a transform
|
||||||
|
to defeat `op run`'s output masking (it masks raw values; base64 hides them
|
||||||
|
from the mask — that exact trick leaked a key on 2026-06-11).
|
||||||
|
2. **Secrets stay in the subprocess.** Reference them only as env vars consumed
|
||||||
|
*inside* `op run --env-file ~/.op-env -- <cmd>`. Never place a literal secret
|
||||||
|
in a command's argv (it lands in the tool call and the transcript).
|
||||||
|
3. **Existence check without revealing the value:** `[ -n "$X" ] && echo set` —
|
||||||
|
never `${X:-...}` (returns the value when set) and never echo a substring of it.
|
||||||
|
4. **Cross-host secrets:** run the secret-consuming command on the host that has
|
||||||
|
the secret; do not forward a raw key over ssh argv/stdout.
|
||||||
|
5. If a secret does leak into output, say so immediately and flag it for rotation —
|
||||||
|
don't bury it.
|
||||||
|
|
||||||
## Infrastructure
|
## Infrastructure
|
||||||
|
|
||||||
Three machines on Tailscale:
|
Three machines on Tailscale:
|
||||||
@@ -160,7 +204,7 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
|||||||
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||||
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||||
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
||||||
| **Browser / human inspection** | `https://gitea.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||||
|
|
||||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||||
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
||||||
@@ -222,15 +266,17 @@ unconditionally on every host, every harness.
|
|||||||
|
|
||||||
## Engineering Skills
|
## Engineering Skills
|
||||||
|
|
||||||
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
|
Shared engineering skills are available in `~/dev/.skills/`. Load at task start — not "on demand" but on schedule, before writing code. See `~/dev/.skills/SKILLS_INDEX.md` for the full list.
|
||||||
|
|
||||||
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
**Skill trigger table — load before starting, not after getting stuck:**
|
||||||
|
|
||||||
Key skills:
|
| Task type | Load |
|
||||||
- **TDD**: always write tests first — load `tdd` skill
|
|-----------|------|
|
||||||
- **Code Review**: load `code-review` skill before any review
|
| Any feature or bug fix | `tdd` |
|
||||||
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
| Refactor or design | `clean-code` or `solid` |
|
||||||
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
| Debug | `problem-analysis` |
|
||||||
|
| Review code or PRs | `code-review` |
|
||||||
|
| Frame a problem before coding | `problem-analysis` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ and climate/sustainability tech.
|
|||||||
|
|
||||||
These rules apply to every task across every project, regardless of harness.
|
These rules apply to every task across every project, regardless of harness.
|
||||||
|
|
||||||
|
0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line:
|
||||||
|
- **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours.
|
||||||
|
- **Load the relevant skill** — see trigger table in *Engineering Skills* below.
|
||||||
|
- **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test.
|
||||||
|
- **State the observable success criterion** — what specific behavior, output, or passing test proves this is done?
|
||||||
|
|
||||||
|
**TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it.
|
||||||
|
|
||||||
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
||||||
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
||||||
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||||
@@ -49,6 +57,22 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
PR flow only when a human reviewer outside the project is required. Document
|
PR flow only when a human reviewer outside the project is required. Document
|
||||||
the reason in PROJECT.md.
|
the reason in PROJECT.md.
|
||||||
|
|
||||||
|
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
||||||
|
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||||
|
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
|
||||||
|
new/changed ADR, patch for a fix; docs in the same commit). Check the repo's
|
||||||
|
actual last tag — stated versions in docs drift stale.
|
||||||
|
- **Push** main and the tag (CI is the gate).
|
||||||
|
- **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) —
|
||||||
|
the reusable patterns and the footguns that would bite anyone again, never
|
||||||
|
project status. See *Knowledge base — when to write* below.
|
||||||
|
- **File discovered-but-deferred work as tracker issues** on the project's own
|
||||||
|
repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let
|
||||||
|
"out of scope, recorded" rot in a commit message; make it a ticket with a
|
||||||
|
source pointer.
|
||||||
|
- Surface the brain entries and issue numbers in the closing summary so the
|
||||||
|
trail is auditable.
|
||||||
|
|
||||||
## Default stack
|
## Default stack
|
||||||
|
|
||||||
| Layer | Default | Fallback | Last resort |
|
| Layer | Default | Fallback | Last resort |
|
||||||
@@ -78,6 +102,26 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
|
|||||||
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||||
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
|
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
|
||||||
|
|
||||||
|
## Secret handling (every harness, every command)
|
||||||
|
|
||||||
|
Tool output is persisted: terminal → `~/.claude/projects` transcripts →
|
||||||
|
claudewatcher → brain/wiki → gitea history. A secret printed once is
|
||||||
|
searchable forever, and clearing it means rotating the key. So:
|
||||||
|
|
||||||
|
1. **Never print, echo, log, or transform a secret to inspect it.** No
|
||||||
|
`base64`/`xxd`/`cat` of a key, and never pipe a secret through a transform
|
||||||
|
to defeat `op run`'s output masking (it masks raw values; base64 hides them
|
||||||
|
from the mask — that exact trick leaked a key on 2026-06-11).
|
||||||
|
2. **Secrets stay in the subprocess.** Reference them only as env vars consumed
|
||||||
|
*inside* `op run --env-file ~/.op-env -- <cmd>`. Never place a literal secret
|
||||||
|
in a command's argv (it lands in the tool call and the transcript).
|
||||||
|
3. **Existence check without revealing the value:** `[ -n "$X" ] && echo set` —
|
||||||
|
never `${X:-...}` (returns the value when set) and never echo a substring of it.
|
||||||
|
4. **Cross-host secrets:** run the secret-consuming command on the host that has
|
||||||
|
the secret; do not forward a raw key over ssh argv/stdout.
|
||||||
|
5. If a secret does leak into output, say so immediately and flag it for rotation —
|
||||||
|
don't bury it.
|
||||||
|
|
||||||
## Infrastructure
|
## Infrastructure
|
||||||
|
|
||||||
Three machines on Tailscale:
|
Three machines on Tailscale:
|
||||||
@@ -157,7 +201,7 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
|||||||
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||||
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||||
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
||||||
| **Browser / human inspection** | `https://gitea.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||||
|
|
||||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||||
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
||||||
@@ -219,15 +263,17 @@ unconditionally on every host, every harness.
|
|||||||
|
|
||||||
## Engineering Skills
|
## Engineering Skills
|
||||||
|
|
||||||
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
|
Shared engineering skills are available in `~/dev/.skills/`. Load at task start — not "on demand" but on schedule, before writing code. See `~/dev/.skills/SKILLS_INDEX.md` for the full list.
|
||||||
|
|
||||||
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
**Skill trigger table — load before starting, not after getting stuck:**
|
||||||
|
|
||||||
Key skills:
|
| Task type | Load |
|
||||||
- **TDD**: always write tests first — load `tdd` skill
|
|-----------|------|
|
||||||
- **Code Review**: load `code-review` skill before any review
|
| Any feature or bug fix | `tdd` |
|
||||||
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
| Refactor or design | `clean-code` or `solid` |
|
||||||
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
| Debug | `problem-analysis` |
|
||||||
|
| Review code or PRs | `code-review` |
|
||||||
|
| Frame a problem before coding | `problem-analysis` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
chassisauth "gitea.d-ma.be/mathias/mcp-chassis/auth"
|
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||||
|
|||||||
@@ -3,13 +3,12 @@ module gitea.d-ma.be/mathias/gitea-mcp
|
|||||||
go 1.26.2
|
go 1.26.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
git.d-ma.be/mathias/mcp-chassis v0.2.0
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||||
github.com/lestrrat-go/jwx/v2 v2.1.6
|
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
gitea.d-ma.be/mathias/mcp-chassis v0.1.0 // indirect
|
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.3 // indirect
|
github.com/goccy/go-json v0.10.3 // indirect
|
||||||
@@ -17,6 +16,7 @@ require (
|
|||||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||||
github.com/lestrrat-go/httprc v1.0.6 // indirect
|
github.com/lestrrat-go/httprc v1.0.6 // indirect
|
||||||
github.com/lestrrat-go/iter v1.0.2 // indirect
|
github.com/lestrrat-go/iter v1.0.2 // indirect
|
||||||
|
github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect
|
||||||
github.com/lestrrat-go/option v1.0.1 // indirect
|
github.com/lestrrat-go/option v1.0.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/segmentio/asm v1.2.0 // indirect
|
github.com/segmentio/asm v1.2.0 // indirect
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
gitea.d-ma.be/mathias/mcp-chassis v0.1.0 h1:8RXO34+n7Vu8HnUMagars6fc4oemqRpMu7MVtjaj4qY=
|
git.d-ma.be/mathias/mcp-chassis v0.2.0 h1:6fLmb7xqRa2nNVWsHaUbbfbArgDXJw/gDhb09clBIjo=
|
||||||
gitea.d-ma.be/mathias/mcp-chassis v0.1.0/go.mod h1:ajbLlwr2L7FAN3TBU39KucZkKJM02wTbKbDKDEW2YvE=
|
git.d-ma.be/mathias/mcp-chassis v0.2.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ package tools
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
@@ -14,22 +17,22 @@ import (
|
|||||||
|
|
||||||
var nameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{1,38}[a-z0-9]$`)
|
var nameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{1,38}[a-z0-9]$`)
|
||||||
|
|
||||||
var substitutionFiles = []string{
|
|
||||||
"go.mod",
|
|
||||||
"Taskfile.yml",
|
|
||||||
"Dockerfile",
|
|
||||||
".gitea/workflows/cd.yml",
|
|
||||||
"README.md",
|
|
||||||
".context/PROJECT.md",
|
|
||||||
}
|
|
||||||
|
|
||||||
func substitutions(owner, name string) map[string]string {
|
func substitutions(owner, name string) map[string]string {
|
||||||
return map[string]string{
|
return map[string]string{
|
||||||
"__PROJECT_NAME__": name,
|
"__PROJECT_NAME__": name,
|
||||||
"__MODULE_PATH__": "gitea.d-ma.be/" + owner + "/" + name,
|
// git.d-ma.be is the canonical module host (the gitea.d-ma.be → git.d-ma.be
|
||||||
|
// rename; a stale host breaks `go mod download` for downstream consumers).
|
||||||
|
"__MODULE_PATH__": "git.d-ma.be/" + owner + "/" + name,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyReplacements(s string, repls map[string]string) string {
|
||||||
|
for k, v := range repls {
|
||||||
|
s = strings.ReplaceAll(s, k, v)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// CreateProjectFromTemplate is the exported type so tests can reference it.
|
// CreateProjectFromTemplate is the exported type so tests can reference it.
|
||||||
type CreateProjectFromTemplate struct {
|
type CreateProjectFromTemplate struct {
|
||||||
c *gitea.Client
|
c *gitea.Client
|
||||||
@@ -45,7 +48,7 @@ func NewCreateProjectFromTemplate(c *gitea.Client, a *allowlist.Allowlist, tmplO
|
|||||||
func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
|
func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
|
||||||
return registry.ToolDescriptor{
|
return registry.ToolDescriptor{
|
||||||
Name: "create_project_from_template",
|
Name: "create_project_from_template",
|
||||||
Description: "Create a new project repo from a template, applying placeholder substitutions to known files. Defaults to the server-configured template; pass template_name to override (e.g. template-go-agent).",
|
Description: "Create a new project repo from a template. Best-effort substitution of placeholders (__PROJECT_NAME__, __MODULE_PATH__) in every file's content AND path (e.g. renaming cmd/__PROJECT_NAME__/): it completes only if the generated branch is promptly writable. If gitea's async generate is slow (infra#179) the repo is still created and partial_failure explains how to finalize locally (`hyperguild new-project`). Check files_substituted and partial_failure. Defaults to the server-configured template; pass template_name to override (e.g. template-go-agent). Pass dispatch_allow=true to also inject a .dispatch-allow file so the project is immediately dispatch-eligible (dispatch#3).",
|
||||||
InputSchema: json.RawMessage(`{
|
InputSchema: json.RawMessage(`{
|
||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
@@ -53,7 +56,8 @@ func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
|
|||||||
"name":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,38}[a-z0-9]$"},
|
"name":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,38}[a-z0-9]$"},
|
||||||
"description":{"type":"string"},
|
"description":{"type":"string"},
|
||||||
"private":{"type":"boolean"},
|
"private":{"type":"boolean"},
|
||||||
"template_name":{"type":"string","description":"Template repo name to generate from. Defaults to the server-configured template."}
|
"template_name":{"type":"string","description":"Template repo name to generate from. Defaults to the server-configured template."},
|
||||||
|
"dispatch_allow":{"type":"boolean","description":"When true, inject a .dispatch-allow file so the new project is immediately opt-in for headless dispatch (dispatch#3). Default false."}
|
||||||
},
|
},
|
||||||
"required":["owner","name"]
|
"required":["owner","name"]
|
||||||
}`),
|
}`),
|
||||||
@@ -66,8 +70,15 @@ type createProjectArgs struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Private bool `json:"private"`
|
Private bool `json:"private"`
|
||||||
TemplateName string `json:"template_name"`
|
TemplateName string `json:"template_name"`
|
||||||
|
DispatchAllow bool `json:"dispatch_allow"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dispatchAllowContent is the body injected when dispatch_allow=true. Mirrors the
|
||||||
|
// sandbox convention: presence of the file (not its content) marks the repo
|
||||||
|
// dispatch-eligible; the comment exists only to explain that to a human reader.
|
||||||
|
const dispatchAllowContent = "# Presence of this file marks this repo as opt-in for headless dispatch.\n" +
|
||||||
|
"# See dispatch#3.\n"
|
||||||
|
|
||||||
type createProjectResult struct {
|
type createProjectResult struct {
|
||||||
FullName string `json:"full_name"`
|
FullName string `json:"full_name"`
|
||||||
HTMLURL string `json:"html_url"`
|
HTMLURL string `json:"html_url"`
|
||||||
@@ -135,21 +146,167 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
|
|||||||
DefaultBranch: newRepo.DefaultBranch,
|
DefaultBranch: newRepo.DefaultBranch,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Substitute placeholders in known files (best-effort).
|
// The /generate response often omits default_branch — resolve it explicitly,
|
||||||
repls := substitutions(args.Owner, args.Name)
|
// otherwise every file read below hits an empty ref and nothing substitutes
|
||||||
|
// (the silent-null bug: gitea-mcp#42).
|
||||||
branch := newRepo.DefaultBranch
|
branch := newRepo.DefaultBranch
|
||||||
for _, path := range substitutionFiles {
|
if branch == "" {
|
||||||
if err := t.c.SubstituteFile(ctx, args.Owner, args.Name, branch, path, repls); err != nil {
|
if r, gerr := t.c.GetRepo(ctx, args.Owner, args.Name); gerr == nil && r.DefaultBranch != "" {
|
||||||
// Files that don't exist in this template are silently skipped.
|
branch = r.DefaultBranch
|
||||||
if errors.Is(err, gitea.ErrNotFound) {
|
} else {
|
||||||
|
branch = "main"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.DefaultBranch = branch
|
||||||
|
|
||||||
|
// Substitute across the WHOLE tree: content in every blob, plus a path rename
|
||||||
|
// for any file whose path carries a placeholder (e.g. cmd/__PROJECT_NAME__/main.go).
|
||||||
|
// A fixed known-files list can't rename directories or cover every templated
|
||||||
|
// file, which is why the old scaffold didn't build.
|
||||||
|
repls := substitutions(args.Owner, args.Name)
|
||||||
|
tree, err := t.c.GetTree(ctx, args.Owner, args.Name, branch, true)
|
||||||
|
if err != nil {
|
||||||
|
result.PartialFailure = fmt.Sprintf("tree walk (%s@%s): %v", args.Name, branch, err)
|
||||||
|
return textOK(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range tree.Tree {
|
||||||
|
if e.Type != "blob" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Any other error halts the substitution pass with partial_failure recorded.
|
substituted, fail := t.substituteEntry(ctx, args.Owner, args.Name, branch, e.Path, repls)
|
||||||
result.PartialFailure = fmt.Sprintf("%s: %v", path, err)
|
if fail != "" {
|
||||||
|
result.PartialFailure = fail
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
result.FilesSubstituted = append(result.FilesSubstituted, path)
|
if substituted != "" {
|
||||||
|
result.FilesSubstituted = append(result.FilesSubstituted, substituted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opt the new project into headless dispatch if asked: presence of a
|
||||||
|
// .dispatch-allow file on the default branch marks it dispatch-eligible
|
||||||
|
// (dispatch#3). Ride the same upsertRetry path as substitution so it inherits
|
||||||
|
// the infra#179 branch-readiness / partial-failure handling below. Skip if the
|
||||||
|
// loop already stalled — a failed injection then degrades identically.
|
||||||
|
if args.DispatchAllow && result.PartialFailure == "" {
|
||||||
|
const dispatchAllowPath = ".dispatch-allow"
|
||||||
|
if err := t.upsertRetry(ctx, args.Owner, args.Name, dispatchAllowPath, gitea.UpsertFileArgs{
|
||||||
|
Branch: branch,
|
||||||
|
Content: base64.StdEncoding.EncodeToString([]byte(dispatchAllowContent)),
|
||||||
|
Message: "dispatch: mark project dispatch-eligible (dispatch#3)",
|
||||||
|
}); err != nil {
|
||||||
|
result.PartialFailure = fmt.Sprintf("write %s: %v", dispatchAllowPath, err)
|
||||||
|
} else {
|
||||||
|
result.FilesSubstituted = append(result.FilesSubstituted, dispatchAllowPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If substitution stalled because the generated branch wasn't writable in time,
|
||||||
|
// the repo IS created — say so clearly and point to the local finalize step,
|
||||||
|
// rather than leaking the raw "branch does not exist" (infra#179: gitea's
|
||||||
|
// template-generate is slow-async on this instance, so tool-side substitution
|
||||||
|
// is best-effort).
|
||||||
|
if strings.Contains(result.PartialFailure, "branch does not exist") ||
|
||||||
|
strings.Contains(result.PartialFailure, "not found") {
|
||||||
|
result.PartialFailure = fmt.Sprintf(
|
||||||
|
"repo created, but its branch (%s) was not writable within %ds — gitea's "+
|
||||||
|
"template-generate is slow-async on this instance (infra#179), so substitution "+
|
||||||
|
"is incomplete (%d file(s) done). Finalize locally with `hyperguild new-project` "+
|
||||||
|
"(clone + substitute, no API race). Underlying: %s",
|
||||||
|
branch, substitutionBudget, len(result.FilesSubstituted), result.PartialFailure)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail loud: a scaffold that still holds placeholders does not build. Nothing
|
||||||
|
// substituted (with no explicit failure) means the walk found no placeholders —
|
||||||
|
// suspicious for a real template. Surface it instead of returning silent success.
|
||||||
|
if result.PartialFailure == "" && len(result.FilesSubstituted) == 0 {
|
||||||
|
result.PartialFailure = fmt.Sprintf("no placeholders substituted in %s@%s — verify the scaffold is not left templated", args.Name, branch)
|
||||||
}
|
}
|
||||||
|
|
||||||
return textOK(result)
|
return textOK(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// substitutionBudget bounds how long we retry the first write while the freshly
|
||||||
|
// generated branch becomes writable. gitea's /generate returns (and serves reads)
|
||||||
|
// before the branch ref is committed, so writes 404 "branch does not exist" for a
|
||||||
|
// window. We keep the budget SHORT so the MCP call stays responsive: a healthy
|
||||||
|
// gitea commits in ~1s and this catches it; a slow one (infra#179, observed >40s)
|
||||||
|
// fails fast and we defer substitution with clear guidance rather than hang.
|
||||||
|
const substitutionBudget = 5
|
||||||
|
|
||||||
|
// upsertRetry retries UpsertFile on the transient post-generate "branch does not
|
||||||
|
// exist" not-found, up to substitutionBudget. The write itself is the readiness
|
||||||
|
// probe — BranchExists reports the branch present before writes succeed.
|
||||||
|
func (t *CreateProjectFromTemplate) upsertRetry(ctx context.Context, owner, name, path string, args gitea.UpsertFileArgs) error {
|
||||||
|
var err error
|
||||||
|
for i := 0; i < substitutionBudget; i++ {
|
||||||
|
if _, err = t.c.UpsertFile(ctx, owner, name, path, args); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, gitea.ErrNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return err
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// substituteEntry substitutes placeholders in one blob. If the path carries a
|
||||||
|
// placeholder it renames the file (write new + delete old); otherwise it rewrites
|
||||||
|
// content in place when changed. Returns a human-readable description of what was
|
||||||
|
// substituted ("" if nothing), and a non-empty partial-failure string on error.
|
||||||
|
func (t *CreateProjectFromTemplate) substituteEntry(ctx context.Context, owner, name, branch, path string, repls map[string]string) (substituted, failure string) {
|
||||||
|
newPath := applyReplacements(path, repls)
|
||||||
|
|
||||||
|
fc, err := t.c.GetFileContents(ctx, owner, name, path, branch)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gitea.ErrNotFound) {
|
||||||
|
return "", "" // vanished between tree walk and read; skip
|
||||||
|
}
|
||||||
|
return "", fmt.Sprintf("read %s: %v", path, err)
|
||||||
|
}
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(fc.Content)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Sprintf("decode %s: %v", path, err)
|
||||||
|
}
|
||||||
|
newContent := applyReplacements(string(decoded), repls)
|
||||||
|
renamed := newPath != path
|
||||||
|
changed := newContent != string(decoded)
|
||||||
|
if !renamed && !changed {
|
||||||
|
return "", "" // nothing to do
|
||||||
|
}
|
||||||
|
enc := base64.StdEncoding.EncodeToString([]byte(newContent))
|
||||||
|
|
||||||
|
if renamed {
|
||||||
|
if err := t.upsertRetry(ctx, owner, name, newPath, gitea.UpsertFileArgs{
|
||||||
|
Branch: branch,
|
||||||
|
Content: enc,
|
||||||
|
Message: fmt.Sprintf("template: substitute + rename %s -> %s", path, newPath),
|
||||||
|
}); err != nil {
|
||||||
|
return "", fmt.Sprintf("write %s: %v", newPath, err)
|
||||||
|
}
|
||||||
|
if _, err := t.c.DeleteFile(ctx, owner, name, path, gitea.DeleteFileArgs{
|
||||||
|
Branch: branch,
|
||||||
|
Sha: fc.Sha,
|
||||||
|
Message: fmt.Sprintf("template: drop placeholder path %s", path),
|
||||||
|
}); err != nil {
|
||||||
|
return "", fmt.Sprintf("delete %s: %v", path, err)
|
||||||
|
}
|
||||||
|
return path + " -> " + newPath, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := t.upsertRetry(ctx, owner, name, path, gitea.UpsertFileArgs{
|
||||||
|
Branch: branch,
|
||||||
|
Content: enc,
|
||||||
|
Message: "template: substitute placeholders",
|
||||||
|
Sha: fc.Sha,
|
||||||
|
}); err != nil {
|
||||||
|
return "", fmt.Sprintf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
return path, ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
@@ -17,306 +19,294 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// substitutionFileList matches the tool's internal list — used to drive fake server routing.
|
func encb64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
|
||||||
var substitutionFileList = []string{
|
|
||||||
"go.mod",
|
|
||||||
"Taskfile.yml",
|
|
||||||
"Dockerfile",
|
|
||||||
".gitea/workflows/cd.yml",
|
|
||||||
"README.md",
|
|
||||||
".context/PROJECT.md",
|
|
||||||
}
|
|
||||||
|
|
||||||
// contentWithPlaceholder is a template file body that contains the placeholder.
|
func templateRepoJSON(name string, isTemplate bool) string {
|
||||||
const contentWithPlaceholder = "# __PROJECT_NAME__\nmodule __MODULE_PATH__\n"
|
return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":%v}`,
|
||||||
|
|
||||||
func encodedContent(s string) string {
|
|
||||||
return base64.StdEncoding.EncodeToString([]byte(s))
|
|
||||||
}
|
|
||||||
|
|
||||||
// fileContentsJSON returns a JSON FileContents object for the given path.
|
|
||||||
func fileContentsJSON(path string) string {
|
|
||||||
enc := encodedContent(contentWithPlaceholder)
|
|
||||||
return fmt.Sprintf(`{"path":%q,"sha":"sha-%s","size":40,"content":%q,"encoding":"base64"}`,
|
|
||||||
path, strings.ReplaceAll(path, "/", "-"), enc)
|
|
||||||
}
|
|
||||||
|
|
||||||
// fileWriteResultJSON returns a minimal FileWriteResult JSON.
|
|
||||||
func fileWriteResultJSON(path string) string {
|
|
||||||
return fmt.Sprintf(`{"content":{"path":%q,"sha":"newsha","html_url":""},"commit":{"sha":"c","html_url":""}}`, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// newTemplateRepoJSON returns a JSON Repo marked as template.
|
|
||||||
func newTemplateRepoJSON(name string, isTemplate bool) string {
|
|
||||||
return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","description":"","private":false,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":%v}`,
|
|
||||||
name, name, name, name, isTemplate)
|
name, name, name, name, isTemplate)
|
||||||
}
|
}
|
||||||
|
|
||||||
// newGeneratedRepoJSON returns the JSON for the newly generated repo.
|
// fakeTemplateServer serves the whole create-from-template flow off an in-memory
|
||||||
func newGeneratedRepoJSON(name string) string {
|
// file map, driving the tool's tree-walk. Records writes/deletes/put-bodies.
|
||||||
return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","description":"","private":false,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":false}`,
|
type fakeTemplateServer struct {
|
||||||
name, name, name, name)
|
mu sync.Mutex
|
||||||
|
files map[string]string // path -> raw (un-substituted) content
|
||||||
|
genBranch string // default_branch returned by /generate ("" to force fallback)
|
||||||
|
generated bool
|
||||||
|
puts []string
|
||||||
|
deletes []string
|
||||||
|
putBodies map[string]string // path -> decoded written content
|
||||||
|
repoGetsPost int // GET dest after generate (branch fallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newCreateProjectTool(srvURL string) *tools.CreateProjectFromTemplate {
|
func newFakeTemplateServer(files map[string]string, genBranch string) *fakeTemplateServer {
|
||||||
c := gitea.NewClient(srvURL, "tok")
|
return &fakeTemplateServer{files: files, genBranch: genBranch, putBodies: map[string]string{}}
|
||||||
a := allowlist.New([]string{"mathias"})
|
|
||||||
return tools.NewCreateProjectFromTemplate(c, a, "mathias", "template-go-web")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCreateProjectHappyPath: all 6 files served and substituted.
|
func (f *fakeTemplateServer) handler(t *testing.T, tmpl, dest string) http.HandlerFunc {
|
||||||
func TestCreateProjectHappyPath(t *testing.T) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
switch {
|
p := r.URL.Path
|
||||||
// Template repo lookup
|
|
||||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
|
|
||||||
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
|
|
||||||
|
|
||||||
// Destination repo lookup — 404 means it doesn't exist yet
|
switch {
|
||||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc":
|
case r.Method == http.MethodGet && p == "/api/v1/repos/mathias/"+tmpl:
|
||||||
|
_, _ = w.Write([]byte(templateRepoJSON(tmpl, true)))
|
||||||
|
|
||||||
|
case r.Method == http.MethodGet && p == "/api/v1/repos/mathias/"+dest:
|
||||||
|
if !f.generated {
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.repoGetsPost++
|
||||||
|
_, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"mathias/%s","default_branch":"main","clone_url":"c","html_url":"h","template":false}`, dest, dest)
|
||||||
|
|
||||||
// Generate
|
case r.Method == http.MethodPost && p == "/api/v1/repos/mathias/"+tmpl+"/generate":
|
||||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/mathias/template-go-web/generate":
|
f.generated = true
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
_, _ = w.Write([]byte(newGeneratedRepoJSON("new-svc")))
|
_, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"mathias/%s","default_branch":%q,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":false}`,
|
||||||
|
dest, dest, f.genBranch, dest, dest)
|
||||||
|
|
||||||
// File contents GET — handle all 6 substitution files
|
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/git/trees/"):
|
||||||
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
|
var entries []string
|
||||||
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
|
for path := range f.files {
|
||||||
_, _ = w.Write([]byte(fileContentsJSON(filePath)))
|
entries = append(entries, fmt.Sprintf(`{"path":%q,"type":"blob","sha":"sha-%s"}`, path, strings.ReplaceAll(path, "/", "-")))
|
||||||
|
}
|
||||||
|
// include a tree (directory) entry to exercise the blob filter
|
||||||
|
entries = append(entries, `{"path":"cmd","type":"tree","sha":"treesha"}`)
|
||||||
|
_, _ = fmt.Fprintf(w, `{"sha":"root","tree":[%s],"truncated":false}`, strings.Join(entries, ","))
|
||||||
|
|
||||||
// File contents PUT — handle all 6 substitution files
|
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/"):
|
||||||
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
|
path := strings.TrimPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/")
|
||||||
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
|
body, ok := f.files[path]
|
||||||
|
if !ok {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(w, `{"path":%q,"sha":"sha-%s","size":1,"content":%q,"encoding":"base64"}`,
|
||||||
|
path, strings.ReplaceAll(path, "/", "-"), encb64(body))
|
||||||
|
|
||||||
|
// POST = create (new/renamed file, no sha), PUT = update (existing, with sha).
|
||||||
|
case (r.Method == http.MethodPost || r.Method == http.MethodPut) && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/"):
|
||||||
|
path := strings.TrimPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/")
|
||||||
|
raw, _ := io.ReadAll(r.Body)
|
||||||
|
var args struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(raw, &args)
|
||||||
|
dec, _ := base64.StdEncoding.DecodeString(args.Content)
|
||||||
|
f.puts = append(f.puts, path)
|
||||||
|
f.putBodies[path] = string(dec)
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
} else {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write([]byte(fileWriteResultJSON(filePath)))
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"content":{"path":"x","sha":"n"},"commit":{"sha":"c"}}`))
|
||||||
|
|
||||||
|
case r.Method == http.MethodDelete && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/"):
|
||||||
|
path := strings.TrimPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/")
|
||||||
|
f.deletes = append(f.deletes, path)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"content":null,"commit":{"sha":"c"}}`))
|
||||||
|
|
||||||
default:
|
default:
|
||||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
t.Errorf("unexpected request: %s %s", r.Method, p)
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
}
|
}
|
||||||
}))
|
}
|
||||||
defer srv.Close()
|
}
|
||||||
|
|
||||||
tool := newCreateProjectTool(srv.URL)
|
func newTool(srvURL, tmpl string) *tools.CreateProjectFromTemplate {
|
||||||
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc","description":"A new service"}`))
|
return tools.NewCreateProjectFromTemplate(
|
||||||
|
gitea.NewClient(srvURL, "tok"), allowlist.New([]string{"mathias"}), "mathias", tmpl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func callTool(t *testing.T, srvURL, tmpl, argsJSON string) createOut {
|
||||||
|
t.Helper()
|
||||||
|
res, err := newTool(srvURL, tmpl).Call(context.Background(), json.RawMessage(argsJSON))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
var out createOut
|
||||||
|
require.NoError(t, json.Unmarshal(res, &out))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
var out struct {
|
type createOut struct {
|
||||||
FullName string `json:"full_name"`
|
FullName string `json:"full_name"`
|
||||||
HTMLURL string `json:"html_url"`
|
|
||||||
CloneURL string `json:"clone_url"`
|
|
||||||
DefaultBranch string `json:"default_branch"`
|
DefaultBranch string `json:"default_branch"`
|
||||||
FilesSubstituted []string `json:"files_substituted"`
|
FilesSubstituted []string `json:"files_substituted"`
|
||||||
PartialFailure string `json:"partial_failure,omitempty"`
|
PartialFailure string `json:"partial_failure,omitempty"`
|
||||||
}
|
}
|
||||||
require.NoError(t, json.Unmarshal(result, &out))
|
|
||||||
|
|
||||||
assert.Equal(t, "mathias/new-svc", out.FullName)
|
// Happy path: whole-tree substitution, content + path rename, correct module host.
|
||||||
assert.Equal(t, "http://gitea.example.com/mathias/new-svc", out.HTMLURL)
|
func TestCreateProject_TreeWalk_SubstitutesAndRenames(t *testing.T) {
|
||||||
|
files := map[string]string{
|
||||||
|
"go.mod": "module __MODULE_PATH__\n\ngo 1.26\n",
|
||||||
|
"README.md": "# __PROJECT_NAME__\n",
|
||||||
|
"cmd/__PROJECT_NAME__/main.go": "package main\nimport \"__MODULE_PATH__/pkg/litellm\"\nconst n = \"__PROJECT_NAME__\"\n",
|
||||||
|
"pkg/litellm/x.go": "package litellm\n", // no placeholder → untouched
|
||||||
|
}
|
||||||
|
f := newFakeTemplateServer(files, "main")
|
||||||
|
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`)
|
||||||
|
|
||||||
assert.Equal(t, "main", out.DefaultBranch)
|
assert.Equal(t, "main", out.DefaultBranch)
|
||||||
assert.ElementsMatch(t, substitutionFileList, out.FilesSubstituted)
|
assert.Empty(t, out.PartialFailure)
|
||||||
|
|
||||||
|
// content-substituted files present; untouched file absent
|
||||||
|
assert.Contains(t, out.FilesSubstituted, "go.mod")
|
||||||
|
assert.Contains(t, out.FilesSubstituted, "README.md")
|
||||||
|
assert.NotContains(t, out.FilesSubstituted, "pkg/litellm/x.go")
|
||||||
|
// path rename recorded as "old -> new"
|
||||||
|
assert.Contains(t, out.FilesSubstituted, "cmd/__PROJECT_NAME__/main.go -> cmd/new-svc/main.go")
|
||||||
|
|
||||||
|
// module host substituted correctly (git.d-ma.be, not gitea.d-ma.be)
|
||||||
|
assert.Equal(t, "module git.d-ma.be/mathias/new-svc\n\ngo 1.26\n", f.putBodies["go.mod"])
|
||||||
|
// rename: new path written, old path deleted
|
||||||
|
assert.Contains(t, f.puts, "cmd/new-svc/main.go")
|
||||||
|
assert.Contains(t, f.deletes, "cmd/__PROJECT_NAME__/main.go")
|
||||||
|
assert.Equal(t, "package main\nimport \"git.d-ma.be/mathias/new-svc/pkg/litellm\"\nconst n = \"new-svc\"\n",
|
||||||
|
f.putBodies["cmd/new-svc/main.go"])
|
||||||
|
// the untouched file was never written
|
||||||
|
assert.NotContains(t, f.puts, "pkg/litellm/x.go")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The /generate response omits default_branch (the live gitea behavior the old
|
||||||
|
// mock hid) → tool must re-fetch the repo and still substitute.
|
||||||
|
func TestCreateProject_EmptyGenerateBranch_FallsBack(t *testing.T) {
|
||||||
|
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
|
||||||
|
f := newFakeTemplateServer(files, "") // generate returns default_branch:""
|
||||||
|
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`)
|
||||||
|
|
||||||
|
assert.Equal(t, "main", out.DefaultBranch, "must resolve branch via GetRepo fallback")
|
||||||
|
assert.GreaterOrEqual(t, f.repoGetsPost, 1, "must re-fetch repo to resolve empty default_branch")
|
||||||
|
assert.Contains(t, out.FilesSubstituted, "go.mod")
|
||||||
|
assert.Equal(t, "module git.d-ma.be/mathias/new-svc\n", f.putBodies["go.mod"])
|
||||||
assert.Empty(t, out.PartialFailure)
|
assert.Empty(t, out.PartialFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCreateProjectTemplateNameOverride (issue #24): per-call template_name overrides the
|
// Fail loud: a template whose files carry no placeholders yields nothing
|
||||||
// server-configured default, so the same binary can generate from template-go-web or
|
// substituted — surface it rather than returning silent success.
|
||||||
// template-go-agent without restart.
|
func TestCreateProject_NothingSubstituted_IsLoud(t *testing.T) {
|
||||||
func TestCreateProjectTemplateNameOverride(t *testing.T) {
|
files := map[string]string{"README.md": "# static, no placeholders\n"}
|
||||||
var templateLookups, generateCalls []string
|
f := newFakeTemplateServer(files, "main")
|
||||||
|
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
assert.Empty(t, out.FilesSubstituted)
|
||||||
switch {
|
assert.NotEmpty(t, out.PartialFailure, "nothing substituted must not be silent success")
|
||||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-agent":
|
|
||||||
templateLookups = append(templateLookups, "template-go-agent")
|
|
||||||
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-agent", true)))
|
|
||||||
|
|
||||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
|
|
||||||
templateLookups = append(templateLookups, "template-go-web")
|
|
||||||
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
|
|
||||||
|
|
||||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-agent":
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
|
||||||
|
|
||||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/generate"):
|
|
||||||
generateCalls = append(generateCalls, r.URL.Path)
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
_, _ = w.Write([]byte(newGeneratedRepoJSON("new-agent")))
|
|
||||||
|
|
||||||
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/"):
|
|
||||||
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/")
|
|
||||||
_, _ = w.Write([]byte(fileContentsJSON(filePath)))
|
|
||||||
|
|
||||||
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/"):
|
|
||||||
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write([]byte(fileWriteResultJSON(filePath)))
|
|
||||||
|
|
||||||
default:
|
|
||||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Write failure mid-pass → partial_failure populated, no Go error.
|
||||||
|
func TestCreateProject_WriteFailure_PartialFailure(t *testing.T) {
|
||||||
|
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
|
||||||
|
f := newFakeTemplateServer(files, "main")
|
||||||
|
base := f.handler(t, "template-go-agent", "new-svc")
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/contents/go.mod") {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"boom"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base(w, r)
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
// Server is configured with template-go-web as the default; call overrides to template-go-agent.
|
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`)
|
||||||
tool := newCreateProjectTool(srv.URL)
|
assert.NotEmpty(t, out.PartialFailure)
|
||||||
_, err := tool.Call(context.Background(), json.RawMessage(
|
assert.Contains(t, out.PartialFailure, "go.mod")
|
||||||
`{"owner":"mathias","name":"new-agent","template_name":"template-go-agent"}`,
|
|
||||||
))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, []string{"template-go-agent"}, templateLookups,
|
|
||||||
"override must direct the template lookup, not the server default")
|
|
||||||
require.Len(t, generateCalls, 1)
|
|
||||||
assert.Equal(t, "/api/v1/repos/mathias/template-go-agent/generate", generateCalls[0],
|
|
||||||
"override must direct the /generate call too")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCreateProjectNameRegexFailure: invalid name returns ErrValidation without hitting network.
|
// dispatch_allow injects a .dispatch-allow file (dispatch#3) only when true.
|
||||||
func TestCreateProjectNameRegexFailure(t *testing.T) {
|
func TestCreateProject_DispatchAllow(t *testing.T) {
|
||||||
tool := tools.NewCreateProjectFromTemplate(
|
tests := []struct {
|
||||||
gitea.NewClient("http://unused", ""),
|
name string
|
||||||
allowlist.New([]string{"mathias"}),
|
argsJSON string
|
||||||
"mathias", "template-go-web",
|
wantFile bool
|
||||||
)
|
}{
|
||||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"INVALID_NAME"}`))
|
{"true injects .dispatch-allow", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`, true},
|
||||||
|
{"false does not inject", `{"owner":"mathias","name":"new-svc","dispatch_allow":false}`, false},
|
||||||
|
{"omitted does not inject", `{"owner":"mathias","name":"new-svc"}`, false},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
files := map[string]string{"go.mod": "module __MODULE_PATH__\n"}
|
||||||
|
f := newFakeTemplateServer(files, "main")
|
||||||
|
srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc"))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
out := callTool(t, srv.URL, "template-go-agent", tc.argsJSON)
|
||||||
|
require.Empty(t, out.PartialFailure)
|
||||||
|
if tc.wantFile {
|
||||||
|
assert.Contains(t, out.FilesSubstituted, ".dispatch-allow")
|
||||||
|
assert.Contains(t, f.puts, ".dispatch-allow")
|
||||||
|
assert.Contains(t, f.putBodies[".dispatch-allow"], "dispatch#3")
|
||||||
|
} else {
|
||||||
|
assert.NotContains(t, out.FilesSubstituted, ".dispatch-allow")
|
||||||
|
assert.NotContains(t, f.puts, ".dispatch-allow")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── guardrails unchanged by the rewrite ──────────────────────────────────────
|
||||||
|
|
||||||
|
func TestCreateProject_NameRegexFailure(t *testing.T) {
|
||||||
|
_, err := tools.NewCreateProjectFromTemplate(
|
||||||
|
gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}), "mathias", "template-go-agent",
|
||||||
|
).Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"INVALID_NAME"}`))
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.ErrorIs(t, err, gitea.ErrValidation)
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCreateProjectAllowlistRejects: owner not in allowlist returns error.
|
func TestCreateProject_AllowlistRejects(t *testing.T) {
|
||||||
func TestCreateProjectAllowlistRejects(t *testing.T) {
|
_, err := tools.NewCreateProjectFromTemplate(
|
||||||
tool := tools.NewCreateProjectFromTemplate(
|
gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}), "mathias", "template-go-agent",
|
||||||
gitea.NewClient("http://unused", ""),
|
).Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"new-svc"}`))
|
||||||
allowlist.New([]string{"mathias"}),
|
|
||||||
"mathias", "template-go-web",
|
|
||||||
)
|
|
||||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"new-svc"}`))
|
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "allowlist")
|
assert.Contains(t, err.Error(), "allowlist")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCreateProjectTemplateNotTemplate: template repo exists but is not marked as template.
|
func TestCreateProject_NotTemplate(t *testing.T) {
|
||||||
func TestCreateProjectTemplateNotTemplate(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
// Template lookup returns a non-template repo.
|
if r.URL.Path == "/api/v1/repos/mathias/template-go-agent" {
|
||||||
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web" {
|
_, _ = w.Write([]byte(templateRepoJSON("template-go-agent", false)))
|
||||||
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", false)))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
_, err := newTool(srv.URL, "template-go-agent").Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
|
||||||
tool := newCreateProjectTool(srv.URL)
|
|
||||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
|
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.ErrorIs(t, err, gitea.ErrValidation)
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCreateProjectDestinationExists: destination repo already exists.
|
func TestCreateProject_DestinationExists(t *testing.T) {
|
||||||
func TestCreateProjectDestinationExists(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
switch {
|
switch r.URL.Path {
|
||||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
|
case "/api/v1/repos/mathias/template-go-agent":
|
||||||
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
|
_, _ = w.Write([]byte(templateRepoJSON("template-go-agent", true)))
|
||||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc":
|
case "/api/v1/repos/mathias/new-svc":
|
||||||
// Destination exists — return 200.
|
_, _ = w.Write([]byte(templateRepoJSON("new-svc", false)))
|
||||||
_, _ = w.Write([]byte(newTemplateRepoJSON("new-svc", false)))
|
|
||||||
default:
|
default:
|
||||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
_, err := newTool(srv.URL, "template-go-agent").Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
|
||||||
tool := newCreateProjectTool(srv.URL)
|
|
||||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
|
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.ErrorIs(t, err, gitea.ErrConflict)
|
assert.ErrorIs(t, err, gitea.ErrConflict)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCreateProjectMidPassSubstitutionFailure: the 4th file (.gitea/workflows/cd.yml) PUT fails;
|
|
||||||
// the first 3 are substituted, partial_failure is populated, no Go error is returned.
|
|
||||||
func TestCreateProjectMidPassSubstitutionFailure(t *testing.T) {
|
|
||||||
// Files that should succeed (index 0-2 in substitutionFileList).
|
|
||||||
successFiles := map[string]bool{
|
|
||||||
"go.mod": true,
|
|
||||||
"Taskfile.yml": true,
|
|
||||||
"Dockerfile": true,
|
|
||||||
}
|
|
||||||
// The 4th file (index 3) is .gitea/workflows/cd.yml — its PUT returns 500.
|
|
||||||
failFile := ".gitea/workflows/cd.yml"
|
|
||||||
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
switch {
|
|
||||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
|
|
||||||
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
|
|
||||||
|
|
||||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc":
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
|
||||||
|
|
||||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/mathias/template-go-web/generate":
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
_, _ = w.Write([]byte(newGeneratedRepoJSON("new-svc")))
|
|
||||||
|
|
||||||
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
|
|
||||||
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
|
|
||||||
_, _ = w.Write([]byte(fileContentsJSON(filePath)))
|
|
||||||
|
|
||||||
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
|
|
||||||
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
|
|
||||||
if filePath == failFile {
|
|
||||||
// Simulate upstream 500.
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
_, _ = w.Write([]byte(`{"message":"internal server error"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !successFiles[filePath] {
|
|
||||||
t.Errorf("unexpected PUT for file: %s", filePath)
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write([]byte(fileWriteResultJSON(filePath)))
|
|
||||||
|
|
||||||
default:
|
|
||||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
tool := newCreateProjectTool(srv.URL)
|
|
||||||
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
|
|
||||||
// Best-effort: no Go error returned, partial state in result.
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var out struct {
|
|
||||||
FullName string `json:"full_name"`
|
|
||||||
FilesSubstituted []string `json:"files_substituted"`
|
|
||||||
PartialFailure string `json:"partial_failure,omitempty"`
|
|
||||||
}
|
|
||||||
require.NoError(t, json.Unmarshal(result, &out))
|
|
||||||
|
|
||||||
// First 3 files should be in FilesSubstituted.
|
|
||||||
assert.Len(t, out.FilesSubstituted, 3)
|
|
||||||
assert.Contains(t, out.FilesSubstituted, "go.mod")
|
|
||||||
assert.Contains(t, out.FilesSubstituted, "Taskfile.yml")
|
|
||||||
assert.Contains(t, out.FilesSubstituted, "Dockerfile")
|
|
||||||
assert.NotContains(t, out.FilesSubstituted, failFile)
|
|
||||||
|
|
||||||
// partial_failure should be non-empty.
|
|
||||||
assert.NotEmpty(t, out.PartialFailure, "partial_failure should be populated on mid-pass failure")
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user