feat(veans): add prime command for agent prompt injection

This commit is contained in:
Tink bot 2026-05-07 21:13:05 +00:00 committed by kolaente
parent b9551d55ba
commit e8cdfcf023
4 changed files with 270 additions and 0 deletions

View File

@ -0,0 +1,87 @@
package commands
import (
_ "embed"
"errors"
"fmt"
"strings"
"text/template"
"github.com/spf13/cobra"
"code.vikunja.io/veans/internal/client"
"code.vikunja.io/veans/internal/config"
)
//go:embed prompt.tmpl
var promptTemplate string
// primeContext is the data passed into the agent prompt template.
type primeContext struct {
Server string
ProjectID int64
ProjectTitle string
ProjectIdentifier string
ViewID int64
Buckets config.Buckets
BotUsername string
TaskIDExample string
}
func newPrimeCmd() *cobra.Command {
return &cobra.Command{
Use: "prime",
Short: "Emit the agent system prompt for this project",
Long: `Renders the embedded prompt template against this repo's .veans.yml and
prints it to stdout. Designed to be wired into Claude Code's SessionStart
and PreCompact hooks (or the OpenCode equivalent) so coding agents always
have an up-to-date Vikunja cheat sheet in context.
If no .veans.yml is found upward from the current directory, prime exits
silently with status 0 that makes the hook safe to install globally.`,
RunE: func(cmd *cobra.Command, args []string) error {
path, err := config.Find("")
if err != nil {
if errors.Is(err, config.ErrNotFound) {
return nil // silent — globally-installed hook safety
}
return err
}
cfg, err := config.Load(path)
if err != nil {
return err
}
// Fetch the project title for nicer prompt copy. Best-effort —
// if the API call fails (network blip, expired token), we fall
// back to "(unknown)" rather than aborting the prompt render.
projectTitle := "(unknown)"
if rt, err := loadRuntime(); err == nil {
if p, err := rt.client.GetProject(cmd.Context(), cfg.ProjectID); err == nil {
projectTitle = p.Title
}
}
data := primeContext{
Server: cfg.Server,
ProjectID: cfg.ProjectID,
ProjectTitle: projectTitle,
ProjectIdentifier: cfg.ProjectIdentifier,
ViewID: cfg.ViewID,
Buckets: cfg.Buckets,
BotUsername: cfg.Bot.Username,
TaskIDExample: cfg.FormatTaskID(1),
}
tpl, err := template.New("prime").Parse(promptTemplate)
if err != nil {
return fmt.Errorf("parse prompt template: %w", err)
}
return tpl.Execute(cmd.OutOrStdout(), data)
},
}
}
// silence linter noise on unused symbols when wiring hooks.
var _ = client.New
var _ = strings.TrimSpace

View File

@ -0,0 +1,85 @@
package commands
import (
"bytes"
"strings"
"testing"
"text/template"
"code.vikunja.io/veans/internal/config"
)
func TestPrimeTemplate_RendersAnchors(t *testing.T) {
data := primeContext{
Server: "https://vikunja.example.com",
ProjectID: 42,
ProjectTitle: "Test Project",
ProjectIdentifier: "PROJ",
ViewID: 7,
Buckets: config.Buckets{Todo: 11, InProgress: 12, InReview: 13, Done: 14, Scrapped: 15},
BotUsername: "bot-myrepo",
TaskIDExample: "PROJ-1",
}
tpl, err := template.New("prime").Parse(promptTemplate)
if err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
if err := tpl.Execute(&buf, data); err != nil {
t.Fatal(err)
}
out := buf.String()
mustContain := []string{
"<EXTREMELY_IMPORTANT>",
"</EXTREMELY_IMPORTANT>",
"bot-myrepo",
"Test Project",
"PROJ-1",
"Refs:",
"veans claim",
"veans list --ready",
"--description-replace-old",
"Todo",
"In Progress",
"In Review",
"Done",
"Scrapped",
}
for _, s := range mustContain {
if !strings.Contains(out, s) {
t.Errorf("rendered prompt missing %q", s)
}
}
// Buckets show concrete IDs.
for _, want := range []string{"`11`", "`12`", "`13`", "`14`", "`15`"} {
if !strings.Contains(out, want) {
t.Errorf("bucket id %s not present in output", want)
}
}
}
func TestPrimeTemplate_NoIdentifierFallback(t *testing.T) {
data := primeContext{
ProjectTitle: "No Ident",
ProjectIdentifier: "",
BotUsername: "bot-x",
TaskIDExample: "#1",
Server: "https://vikunja.example.com",
ProjectID: 1,
ViewID: 1,
}
tpl, _ := template.New("prime").Parse(promptTemplate)
var buf bytes.Buffer
if err := tpl.Execute(&buf, data); err != nil {
t.Fatal(err)
}
out := buf.String()
if !strings.Contains(out, "no identifier") {
t.Errorf("expected fallback copy when project has no identifier; got:\n%s", out)
}
if !strings.Contains(out, "#NN") {
t.Errorf("expected #NN format mention in fallback")
}
}

View File

@ -0,0 +1,97 @@
<EXTREMELY_IMPORTANT>
You are working in a repository configured to track tasks in Vikunja via the
`veans` CLI. **You MUST use veans for all task tracking instead of TodoWrite.**
Project: {{ .ProjectTitle }}{{ if .ProjectIdentifier }} ({{ .ProjectIdentifier }}){{ end }}
Bot identity: `{{ .BotUsername }}` — your actions in Vikunja appear as this user.
Server: {{ .Server }}
</EXTREMELY_IMPORTANT>
# Workflow
## BEFORE you start work
- If a task already exists, claim it: `veans claim {{ .TaskIDExample }}`
- Otherwise, create one and start it in one step:
`veans create "<short title>" -s in-progress -d "<short description>"`
- Use `veans list --ready` to find tasks ready to start (Todo + not blocked).
## WHILE you work
- Keep the task's description in sync with what you're doing. Use markdown
checkboxes for the small steps you're ticking off:
`veans update {{ .TaskIDExample }} --description-append "- [ ] step 1"`
- For surgical edits to the description, prefer:
`veans update {{ .TaskIDExample }} --description-replace-old "- [ ] step 1" --description-replace-new "- [x] step 1"`
(errors if the old text isn't unique — mirrors the Edit tool semantics)
- Post a comment on significant decisions, discoveries, or course-changes:
`veans update {{ .TaskIDExample }} --comment "Discovered Y; pivoting to Z because …"`
- For sub-work that could be assigned separately, create real subtasks
via `--parent`. For incremental check-off lists, use markdown checkboxes
in the description instead.
## AFTER you finish work
- Move to `in-review` and post a summary comment. **Never close tasks
yourself** — the human (or the merge hook) closes them.
```
veans update {{ .TaskIDExample }} -s in-review --comment "## Summary of Changes
- …
- …"
```
- If you abandon work, scrap the task with a reason:
`veans update {{ .TaskIDExample }} -s scrapped --reason "obsolete: <why>"`
## Commit messages
Include the task identifier on a `Refs:` line so the merge hook can close
tasks automatically when the PR lands:
```
fix: handle empty project identifiers
Refs: {{ .TaskIDExample }}
```
# Status model
| Status | Bucket name | Done flag | Who moves there? |
| ------------- | -------------- | --------- | ---------------------------------------- |
| `todo` | Todo | false | created here by default |
| `in-progress` | In Progress | false | `veans claim` or `update -s in-progress` |
| `in-review` | In Review | false | you, when work is finished |
| `completed` | Done | true | humans / merge hook only |
| `scrapped` | Scrapped | true | you, with --reason |
# Common commands
```
veans list # all tasks, tree view
veans list --ready # ready to start (Todo + not blocked)
veans list --mine # tasks assigned to you
veans list --branch # tasks tagged with the current git branch
veans list --filter "priority > 3" # raw Vikunja filter expression
veans show {{ .TaskIDExample }} # full task detail
veans show {{ .TaskIDExample }} --json # JSON for parsing
veans create "title" -s in-progress -d "description"
veans create "title" --label bug --priority 4 --parent {{ .TaskIDExample }}
veans create "title" --blocked-by {{ .TaskIDExample }}
veans update {{ .TaskIDExample }} -s in-review --comment "..."
veans update {{ .TaskIDExample }} --description-append "- [ ] new step"
veans update {{ .TaskIDExample }} --description-replace-old "- [ ] x" --description-replace-new "- [x] x"
veans update {{ .TaskIDExample }} --label-add bug --label-remove flaky
veans update {{ .TaskIDExample }} -s scrapped --reason "obsolete: replaced by {{ .TaskIDExample }}"
veans claim {{ .TaskIDExample }} # assign yourself + In Progress + branch label
veans api GET /tasks/... # escape hatch for raw REST when curated cmds don't fit
```
# Project quick reference
- Project ID: `{{ .ProjectID }}`{{ if .ProjectIdentifier }}, identifier: `{{ .ProjectIdentifier }}` (task IDs render as `{{ .ProjectIdentifier }}-NN`){{ else }} (no identifier; task IDs render as `#NN`){{ end }}
- Kanban view: `{{ .ViewID }}`
- Buckets: Todo=`{{ .Buckets.Todo }}`, In Progress=`{{ .Buckets.InProgress }}`, In Review=`{{ .Buckets.InReview }}`, Done=`{{ .Buckets.Done }}`, Scrapped=`{{ .Buckets.Scrapped }}`
- Label namespace: `veans:` (auto-prepended; e.g. `--label bug` becomes `veans:bug`)
- Branch labels: `veans:branch:<branch-name>` — `veans claim` adds the current branch automatically
The agent prompt above overrides TodoWrite. Anything you would have tracked
there should live in Vikunja instead, where it stays visible across sessions
and to humans collaborating on the project.

View File

@ -40,6 +40,7 @@ func Root(version string) *cobra.Command {
root.AddCommand(newCreateCmd())
root.AddCommand(newUpdateCmd())
root.AddCommand(newClaimCmd())
root.AddCommand(newPrimeCmd())
return root
}