From e8cdfcf023ff5cf6ecf8f5be2b1533e00c236e70 Mon Sep 17 00:00:00 2001 From: Tink bot Date: Thu, 7 May 2026 21:13:05 +0000 Subject: [PATCH] feat(veans): add prime command for agent prompt injection --- veans/internal/commands/prime.go | 87 ++++++++++++++++++++++++ veans/internal/commands/prime_test.go | 85 +++++++++++++++++++++++ veans/internal/commands/prompt.tmpl | 97 +++++++++++++++++++++++++++ veans/internal/commands/root.go | 1 + 4 files changed, 270 insertions(+) create mode 100644 veans/internal/commands/prime.go create mode 100644 veans/internal/commands/prime_test.go create mode 100644 veans/internal/commands/prompt.tmpl diff --git a/veans/internal/commands/prime.go b/veans/internal/commands/prime.go new file mode 100644 index 000000000..5fe551a1d --- /dev/null +++ b/veans/internal/commands/prime.go @@ -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 diff --git a/veans/internal/commands/prime_test.go b/veans/internal/commands/prime_test.go new file mode 100644 index 000000000..329467727 --- /dev/null +++ b/veans/internal/commands/prime_test.go @@ -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{ + "", + "", + "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") + } +} diff --git a/veans/internal/commands/prompt.tmpl b/veans/internal/commands/prompt.tmpl new file mode 100644 index 000000000..496a5b534 --- /dev/null +++ b/veans/internal/commands/prompt.tmpl @@ -0,0 +1,97 @@ + +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 }} + + +# 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 "" -s in-progress -d ""` +- 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: "` + +## 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:` — `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. diff --git a/veans/internal/commands/root.go b/veans/internal/commands/root.go index 29e603d7f..224f92064 100644 --- a/veans/internal/commands/root.go +++ b/veans/internal/commands/root.go @@ -40,6 +40,7 @@ func Root(version string) *cobra.Command { root.AddCommand(newCreateCmd()) root.AddCommand(newUpdateCmd()) root.AddCommand(newClaimCmd()) + root.AddCommand(newPrimeCmd()) return root }