Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ Please choose versions by [Semantic Versioning](http://semver.org/).
* MINOR version when you add functionality in a backwards-compatible manner, and
* PATCH version when you make backwards-compatible bug fixes.

## Unreleased

- feat: `vault-cli watch --vault a,b` accepts a comma-separated vault list, watching every named vault in one process and stamping each event with its own `vault`. Whitespace around names is ignored, empty entries between commas are skipped, and a value that names no vault (for example `,`) fails with an error naming the value instead of silently widening to every vault. A single name and an omitted flag behave exactly as before, and every other command keeps single-vault `--vault` semantics through `getVaults`.

## v0.131.10

- docs: the four writing guides in `docs/` no longer instruct the reader to run commands that were never shipped. `theme-writing.md` and `objective-writing.md` each opened their TL;DR with a `Create:` line naming `/vault-cli:create-theme` and `/vault-cli:create-objective`; neither exists — not as a command (the plugin ships `create-task.md` and `create-goal.md` only), not as an agent (`theme-auditor` and `objective-auditor` ship, but there is no `theme-creator` or `objective-creator`), and not as a CLI subcommand (`vault-cli theme` and `vault-cli objective` expose `add`/`clear`/`get`/`lint`/`list`/`remove`/`search`/`set`/`show`, with no `create`). A reader following either guide dead-ended, and the correct path was documented nowhere. Both now state it: hand-author the file in the configured `themes_dir` / `objectives_dir`, with the vault's own `theme_template` / `objective_template` available as a pointer. Separately, `task-writing.md` and `goal-writing.md` documented the legacy `todo` status as canonical — in their frontmatter examples, their valid-values lists, and their lifecycle tables — while `pkg/domain/task_status.go` declares `next` canonical and maps `"todo" → next` on read. Both now list `next` first and name `todo` as a read-only legacy alias. Phase `todo` is deliberately untouched: it remains canonical for `phase` (`pkg/domain/task_phase.go`, `pkg/domain/goal_phase.go`), and only the `status` occurrences changed. Finally, both docs hardcoded a vault directory (`24 Tasks/`, `23 Goals/`) on the very line that states the agent must never hardcode paths; those values are another vault's shape, so the parenthetical is gone and the "never hardcode paths" sentence now stands alone. Docs-only.
Expand Down
5 changes: 4 additions & 1 deletion docs/development-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,14 @@ Each entity (Task, Goal, Theme, Objective, Vision) cleanly separates three conce

## Multi-Vault Pattern

All commands use `getVaults()` to resolve vaults:
All commands except `watch` use `getVaults()` to resolve vaults:

- `--vault NAME` → single vault
- No flag → all configured vaults

- `watch --vault a,b` accepts a comma-separated vault list and resolves it through `getWatchVaults`; every other command keeps `getVaults` and a single vault name.
A value that yields no usable name is an error; the empty string means every configured vault.

Commands iterate vaults and call operations per vault. For mutation commands (complete, defer, ack), try each vault until the item is found.

## Output Format
Expand Down
190 changes: 190 additions & 0 deletions integration/watch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
// Copyright (c) 2026 Benjamin Borbe All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package integration_test

import (
"os"
"os/exec"
"path/filepath"
"time"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/onsi/gomega/gexec"
)

// watchProbe writes a markdown file into each given watched directory.
//
// Call it from inside Eventually. The watcher registers its directories when it
// starts and emits no ready signal, so a single write can race that registration
// and be missed; rewriting on every poll is the retry.
func watchProbe(paths ...string) {
for _, path := range paths {
Expect(os.WriteFile(path, []byte("---\nstatus: next\n---\n"), 0600)).To(Succeed())
}
}

// watchStdout returns everything the watcher process has written to stdout so far.
func watchStdout(session *gexec.Session) string {
return string(session.Out.Contents())
}

var _ = Describe("vault-cli watch --vault comma list", func() {
It("AC1: watch --vault alpha,beta emits events for both vaults on one stream", func() {
vaultPathA, vaultPathB, configPath, cleanup := createTwoTempVaults(nil, nil, nil, nil)
defer cleanup()

cmd := exec.Command(binPath, "--config", configPath, "watch", "--vault", "alpha,beta")
session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter)
Expect(err).NotTo(HaveOccurred())
defer session.Kill()

alphaProbe := filepath.Join(vaultPathA, "Tasks", "alpha-probe.md")
betaProbe := filepath.Join(vaultPathB, "Tasks", "beta-probe.md")

Eventually(func() string {
watchProbe(alphaProbe, betaProbe)
return watchStdout(session)
}, 10*time.Second, 250*time.Millisecond).Should(ContainSubstring(`"vault":"alpha"`))

Eventually(func() string {
watchProbe(alphaProbe, betaProbe)
return watchStdout(session)
}, 10*time.Second, 250*time.Millisecond).Should(ContainSubstring(`"vault":"beta"`))
})

It("AC2: watch --vault alpha watches only alpha", func() {
vaultPathA, vaultPathB, configPath, cleanup := createTwoTempVaults(nil, nil, nil, nil)
defer cleanup()

cmd := exec.Command(binPath, "--config", configPath, "watch", "--vault", "alpha")
session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter)
Expect(err).NotTo(HaveOccurred())
defer session.Kill()

alphaProbe := filepath.Join(vaultPathA, "Tasks", "alpha-probe.md")
betaProbe := filepath.Join(vaultPathB, "Tasks", "beta-probe.md")

// An alpha event proves the watcher is up and watching alpha.
Eventually(func() string {
watchProbe(alphaProbe)
return watchStdout(session)
}, 10*time.Second, 250*time.Millisecond).Should(ContainSubstring(`"vault":"alpha"`))

// beta was not named, so a change inside it must produce nothing.
watchProbe(betaProbe)
Consistently(func() string {
return watchStdout(session)
}, "1s", "100ms").ShouldNot(ContainSubstring(`"vault":"beta"`))
})

It("AC2: an omitted --vault still watches every configured vault", func() {
vaultPathA, vaultPathB, configPath, cleanup := createTwoTempVaults(nil, nil, nil, nil)
defer cleanup()

cmd := exec.Command(binPath, "--config", configPath, "watch")
session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter)
Expect(err).NotTo(HaveOccurred())
defer session.Kill()

alphaProbe := filepath.Join(vaultPathA, "Tasks", "alpha-probe.md")
betaProbe := filepath.Join(vaultPathB, "Tasks", "beta-probe.md")

Eventually(func() string {
watchProbe(alphaProbe, betaProbe)
return watchStdout(session)
}, 10*time.Second, 250*time.Millisecond).Should(ContainSubstring(`"vault":"alpha"`))

Eventually(func() string {
watchProbe(alphaProbe, betaProbe)
return watchStdout(session)
}, 10*time.Second, 250*time.Millisecond).Should(ContainSubstring(`"vault":"beta"`))
})

It("AC3: an unresolvable name fails loudly", func() {
_, _, configPath, cleanup := createTwoTempVaults(nil, nil, nil, nil)
defer cleanup()

cmd := exec.Command(binPath, "--config", configPath, "watch", "--vault", "alpha,nope")
session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter)
Expect(err).NotTo(HaveOccurred())

Eventually(session).Should(gexec.Exit(1))
Expect(string(session.Err.Contents())).To(ContainSubstring("nope"))
Expect(watchStdout(session)).NotTo(ContainSubstring(`"vault"`))
})

It("AC3: a value that names no vault fails loudly", func() {
_, _, configPath, cleanup := createTwoTempVaults(nil, nil, nil, nil)
defer cleanup()

cmd := exec.Command(binPath, "--config", configPath, "watch", "--vault", ",")
session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter)
Expect(err).NotTo(HaveOccurred())

Eventually(session).Should(gexec.Exit(1))
Expect(string(session.Err.Contents())).To(ContainSubstring(","))
Expect(watchStdout(session)).NotTo(ContainSubstring(`"vault"`))
})

It("AC4: no other command's --vault accepts a comma list", func() {
_, _, configPath, cleanup := createTwoTempVaults(nil, nil, nil, nil)
defer cleanup()

cmd := exec.Command(binPath, "--config", configPath, "task", "list", "--vault", "alpha,beta")
session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter)
Expect(err).NotTo(HaveOccurred())

Eventually(session).Should(gexec.Exit(1))
Expect(
string(session.Err.Contents()),
).To(ContainSubstring("vault not found: alpha,beta"))
})

It("DB6: --types still filters per vault across a comma list", func() {
vaultPathA, _, configPath, cleanup := createTwoTempVaults(nil, nil, nil, nil)
defer cleanup()

cmd := exec.Command(
binPath, "--config", configPath, "watch", "--vault", "alpha,beta", "--types", "goal",
)
session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter)
Expect(err).NotTo(HaveOccurred())
defer session.Kill()

goalProbe := filepath.Join(vaultPathA, "Goals", "alpha-probe-goal.md")

Eventually(func() string {
watchProbe(goalProbe)
return watchStdout(session)
}, 10*time.Second, 250*time.Millisecond).Should(
And(ContainSubstring(`"type":"goal"`), ContainSubstring(`"vault":"alpha"`)),
)

watchProbe(filepath.Join(vaultPathA, "Tasks", "alpha-probe-task.md"))
Consistently(func() string {
return watchStdout(session)
}, "1s", "100ms").ShouldNot(ContainSubstring(`"type":"task"`))
})

It("failure mode: a vault with no task directory does not break the list", func() {
vaultPathA, vaultPathB, configPath, cleanup := createTwoTempVaults(nil, nil, nil, nil)
defer cleanup()

Expect(os.RemoveAll(filepath.Join(vaultPathB, "Tasks"))).To(Succeed())

cmd := exec.Command(binPath, "--config", configPath, "watch", "--vault", "alpha,beta")
session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter)
Expect(err).NotTo(HaveOccurred())
defer session.Kill()

alphaProbe := filepath.Join(vaultPathA, "Tasks", "alpha-probe.md")

Eventually(func() string {
watchProbe(alphaProbe)
return watchStdout(session)
}, 10*time.Second, 250*time.Millisecond).Should(ContainSubstring(`"vault":"alpha"`))
})
})
45 changes: 44 additions & 1 deletion pkg/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,45 @@ func getVaults(
return (*configLoader).GetAllVaults(ctx)
}

// getWatchVaults returns the vaults the watch command should watch.
//
// A comma-separated value selects exactly the named vaults: whitespace around
// each name is ignored and empty entries between commas are skipped. A value
// that yields no usable name (for example "," or " ") is an error, not a silent
// fallback to every vault. The empty string means the flag was not set and
// selects every configured vault, exactly as getVaults does.
func getWatchVaults(
ctx context.Context,
configLoader *config.Loader,
vaultName *string,
) ([]*config.Vault, error) {
if *vaultName == "" {
return (*configLoader).GetAllVaults(ctx)
}

names := make([]string, 0, strings.Count(*vaultName, ",")+1)
for _, entry := range strings.Split(*vaultName, ",") {
name := strings.TrimSpace(entry)
if name == "" {
continue
}
names = append(names, name)
}
if len(names) == 0 {
return nil, errors.Errorf(ctx, "no vault name in --vault %q", *vaultName)
}

vaults := make([]*config.Vault, 0, len(names))
for _, name := range names {
vault, err := (*configLoader).GetVault(ctx, name)
if err != nil {
return nil, err
}
vaults = append(vaults, vault)
}
return vaults, nil
}

// mutationRunner is the function signature for running a mutation on a single vault.
type mutationRunner func(ctx context.Context, vault *config.Vault) (ops.MutationResult, error)

Expand Down Expand Up @@ -2363,6 +2402,10 @@ Each event includes:
path - vault-relative file path
type - entity kind: task, goal, theme, objective

Use --vault with a comma-separated vault list to watch several vaults in one
process, e.g. --vault personal,trading. Omit --vault to watch every configured
vault. Every event names its own vault in the vault field.

Use --types to filter to a subset of entity kinds.
Valid type values: task, goal, theme, objective`,
Args: cobra.NoArgs,
Expand All @@ -2372,7 +2415,7 @@ Valid type values: task, goal, theme, objective`,
return err
}

vaults, err := getVaults(ctx, configLoader, vaultName)
vaults, err := getWatchVaults(ctx, configLoader, vaultName)
if err != nil {
return errors.Wrap(ctx, err, "get vaults")
}
Expand Down
9 changes: 9 additions & 0 deletions pkg/cli/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,12 @@ func CreateResolveCommandForTest(
) *cobra.Command {
return createResolveCommand(ctx, configLoader, vaultName, outputFormat, newResolveOp)
}

// GetWatchVaultsForTest exposes getWatchVaults for testing.
func GetWatchVaultsForTest(
ctx context.Context,
configLoader *config.Loader,
vaultName *string,
) ([]*config.Vault, error) {
return getWatchVaults(ctx, configLoader, vaultName)
}
Loading
Loading