From 83469e40c98b16ecb9d7eae601828564d81a0bfd Mon Sep 17 00:00:00 2001 From: Sahil Ahuja Date: Sat, 13 Jun 2026 13:32:26 +0530 Subject: [PATCH 1/4] feat: system-level backlog and out-of-git operation Add a --system persistent flag and graceful out-of-git fallback so idea works outside a git repository against ~/.config/idea/backlog.md (XDG-aware via os.UserConfigDir). Resolution precedence: --system -> --file/IDEAS_FILE -> --main -> in-repo default (fab/backlog.md) -> out-of-git system fallback. --system + --main is a conflict error; the config dir is created on demand on first write. File format and all CRUD semantics are unchanged; no new dependencies. --- docs/memory/cli/index.md | 2 +- docs/memory/cli/structure.md | 54 ++++- docs/specs/overview.md | 16 +- .../.history.jsonl | 12 ++ .../.status.yaml | 49 +++++ .../intake.md | 117 +++++++++++ .../260613-2b3m-system-level-backlog/plan.md | 168 +++++++++++++++ src/cmd/idea/add.go | 6 +- src/cmd/idea/done.go | 6 +- src/cmd/idea/edit.go | 5 +- src/cmd/idea/fmt.go | 5 +- src/cmd/idea/list.go | 6 +- src/cmd/idea/main.go | 4 +- src/cmd/idea/main_test.go | 112 ++++++++++ src/cmd/idea/prune.go | 6 +- src/cmd/idea/reopen.go | 6 +- src/cmd/idea/resolve.go | 15 +- src/cmd/idea/rm.go | 6 +- src/cmd/idea/show.go | 6 +- src/internal/idea/idea.go | 97 ++++++++- src/internal/idea/idea_test.go | 192 ++++++++++++++++++ 21 files changed, 850 insertions(+), 40 deletions(-) create mode 100644 fab/changes/260613-2b3m-system-level-backlog/.history.jsonl create mode 100644 fab/changes/260613-2b3m-system-level-backlog/.status.yaml create mode 100644 fab/changes/260613-2b3m-system-level-backlog/intake.md create mode 100644 fab/changes/260613-2b3m-system-level-backlog/plan.md diff --git a/docs/memory/cli/index.md b/docs/memory/cli/index.md index 15913db..09a9488 100644 --- a/docs/memory/cli/index.md +++ b/docs/memory/cli/index.md @@ -10,5 +10,5 @@ description: "CLI source structure (cmd/idea + internal/idea + version wiring), | [edit](edit.md) | `idea edit` two-form contract: inline replacement (two-arg form) vs. $EDITOR round-trip (`edit `) — editor resolution chain, temp-file mechanics, edge/exit semantics, and the fake-editor test seam | 2026-06-12 | | [list](list.md) | `idea list`/`ls` rendering contract: TTY-aware rune-safe text truncation, the `--full` flag, the optional `[id...]` positional filter, ANSI color (NO_COLOR-gated), and the pipe contract that keeps piped output canonical | 2026-06-13 | | [prune](prune.md) | Bulk-remove subcommand (`idea prune`): the TTY × `--force` decision matrix (pipe dry-run vs. interactive `[y/N]` confirm), the leading stderr count header, TTY-aware truncation/color/`--full` listing, stdout/stderr channel split, exit codes, the deliberate non-archival design, and the `removeIdeaAt` seam shared with `Rm` | 2026-06-13 | -| [structure](structure.md) | Source tree layout (cmd/idea + internal/idea), root command factory, command aliases vs. the bare-text shorthand, backlog line lifecycle (lenient read / canonical write incl. the escaped-text convention for multiline ideas and the explicit `idea fmt` canonicalizer with bare-checkbox adoption), the TTY/width/color/truncation seam (internal/idea/term.go) + shared printIdeaLines render path, the golang.org/x/term direct dependency, help-dump contract, and version stamping | 2026-06-13 | +| [structure](structure.md) | Source tree layout (cmd/idea + internal/idea), root command factory, backlog path resolution precedence (--system / --main / --file, the XDG ~/.config/idea/backlog.md system backlog, and the out-of-git graceful fallback), command aliases vs. the bare-text shorthand, backlog line lifecycle (lenient read / canonical write incl. the escaped-text convention for multiline ideas and the explicit `idea fmt` canonicalizer with bare-checkbox adoption), the TTY/width/color/truncation seam (internal/idea/term.go) + shared printIdeaLines render path, the golang.org/x/term direct dependency, help-dump contract, and version stamping | 2026-06-13 | | [update](update.md) | Self-update subcommand (`idea update`): Homebrew-backed upgrade flow, non-brew install fallback hint, and the `--skip-brew-update` flag | 2026-06-10 | diff --git a/docs/memory/cli/structure.md b/docs/memory/cli/structure.md index 0a57f55..3720ae2 100644 --- a/docs/memory/cli/structure.md +++ b/docs/memory/cli/structure.md @@ -1,5 +1,5 @@ --- -description: "Source tree layout (cmd/idea + internal/idea), root command factory, command aliases vs. the bare-text shorthand, backlog line lifecycle (lenient read / canonical write incl. the escaped-text convention for multiline ideas and the explicit `idea fmt` canonicalizer with bare-checkbox adoption), the TTY/width/color/truncation seam (internal/idea/term.go) + shared printIdeaLines render path, the golang.org/x/term direct dependency, help-dump contract, and version stamping" +description: "Source tree layout (cmd/idea + internal/idea), root command factory, backlog path resolution precedence (--system / --main / --file, the XDG ~/.config/idea/backlog.md system backlog, and the out-of-git graceful fallback), command aliases vs. the bare-text shorthand, backlog line lifecycle (lenient read / canonical write incl. the escaped-text convention for multiline ideas and the explicit `idea fmt` canonicalizer with bare-checkbox adoption), the TTY/width/color/truncation seam (internal/idea/term.go) + shared printIdeaLines render path, the golang.org/x/term direct dependency, help-dump contract, and version stamping" --- # CLI Source Structure @@ -60,11 +60,55 @@ Rule of thumb: Two principles in `fab/project/constitution.md` constrain code placement inside this layout: -- **Principle III (Cobra-Idiomatic CLI Surface)** — subcommands are `*cobra.Command` factory functions in `cmd/idea/`; root command exposes the bare-text shorthand (`idea ` → `idea add `); persistent flags (`--file`, `--main`) are defined on root. -- **Principle IV (Logic Lives in `internal/idea`)** — parsing, formatting, ID generation, file I/O, and worktree resolution live in `internal/idea`. `cmd/` files contain only flag wiring, argument validation, and output formatting. +- **Principle III (Cobra-Idiomatic CLI Surface)** — subcommands are `*cobra.Command` factory functions in `cmd/idea/`; root command exposes the bare-text shorthand (`idea ` → `idea add `); persistent flags (`--file`, `--main`, `--system`) are defined on root. +- **Principle IV (Logic Lives in `internal/idea`)** — parsing, formatting, ID generation, file I/O, worktree resolution, and backlog path resolution live in `internal/idea`. `cmd/` files contain only flag wiring, argument validation, and output formatting. The full path-resolution precedence is owned by `idea.ResolveBacklogPath` (see *Backlog path resolution* below); `resolveFile()` in `cmd/idea/resolve.go` is a one-line forwarder of the three persistent-flag values, holding no precedence logic. The split forces a testable seam — `internal/idea` is unit-tested directly (table-driven, real temp dirs, no mocks) without spawning subprocesses. `cmd/idea/main_test.go` covers the end-to-end CLI by building the binary under test. +## Backlog path resolution + +Which backlog file a command operates on is decided by `idea.ResolveBacklogPath(systemFlag, mainFlag bool, fileFlag string) (string, error)` in `internal/idea/idea.go`. This is the sole owner of the precedence (Constitution IV); `cmd/idea/resolve.go`'s `resolveFile()` only forwards the three persistent-flag values into it. The out-of-git fallback and the system backlog were added by `260613-2b3m-system-level-backlog`, which made `idea` usable outside any git repository — previously every command shelled out to `git rev-parse` and **failed hard** ("not in a git repository") in any non-repo directory, with `--file`/`IDEAS_FILE` unable to rescue it because their values were still joined to the failed git root. + +### Three persistent root selectors + +Defined on root in `newRootCmd()` (`cmd/idea/main.go`): + +| Flag | Var | Effect | +|------|-----|--------| +| `--file ` / `IDEAS_FILE` env | `fileFlag` | Override the backlog file path; rooted at the git root inside a repo, else at `~/.config/idea` (an absolute value is honored verbatim). | +| `--main` | `mainFlag` | Operate on the **main worktree's** backlog. Git-only (errors outside a repo) — unchanged by 2b3m. | +| `--system` | `systemFlag` | Operate on the **system backlog**, from anywhere including inside a repo; skips git entirely. Peer of `--main`, added by 2b3m. | + +### Precedence (first match wins) + +`ResolveBacklogPath` resolves in this order: + +1. **`--system`** → the system backlog (`SystemBacklogPath()`); git is skipped entirely. +2. **`--main`** → the main worktree root (`MainRepoRoot()`), then `--file`/`IDEAS_FILE` rooting applied via `ResolveFilePath`. Git-only — errors with "not in a git repository" outside a repo (unchanged). +3. **Inside a git repo, no `--system`/`--main`** → `WorktreeRoot()` succeeds → `ResolveFilePath(worktreeRoot, fileFlag)`: a `--file`/`IDEAS_FILE` override joined to the worktree root, else the **unchanged default** `{worktree-root}/fab/backlog.md`. +4. **Outside any git repo, no `--system`/`--main`** → `WorktreeRoot()` errors → the **graceful fallback**: a relative `--file`/`IDEAS_FILE` value is joined to `~/.config/idea`, an absolute one is honored verbatim, and with no override the path is the system backlog (`{config-dir}/idea/backlog.md`). Commands no longer fail outside a repo. + +**`--system` + `--main` is a hard conflict.** Both select a root; passing both returns `--system and --main are mutually exclusive; pass only one` (non-zero exit via the existing top-level `ERROR:` handler) and resolves no path. The check is the first line of `ResolveBacklogPath`, colocated with the precedence it guards rather than in a separate cobra `PreRunE`. + +### System backlog location (XDG) + +`SystemBacklogPath() (string, error)` returns `{config-dir}/idea/backlog.md`, where `config-dir` comes from Go stdlib `os.UserConfigDir()` — `$XDG_CONFIG_HOME` when set, else `~/.config` on Unix. So: + +- `XDG_CONFIG_HOME=/custom/cfg` → `/custom/cfg/idea/backlog.md` +- unset, `HOME=/home/u` → `/home/u/.config/idea/backlog.md` + +This mirrors `hop`'s `~/.config/hop/hop.yaml` convention and stays stdlib-only — **no new dependency** (Dependency Discipline). `os.UserConfigDir` is the only XDG-resolution path; both `SystemBacklogPath` and the out-of-git override-rooting branch use it. + +### On-demand config-dir creation + +The system config dir (`~/.config/idea/`) is created lazily on the **first mutating write**, not on read. The `os.MkdirAll(filepath.Dir(path), 0755)` lives at the single SaveFile serialization seam — `atomicWriteFile` — so every SaveFile-based mutation (`done`/`reopen`/`edit`/`rm`/`prune --force`/`fmt`) creates a missing dir before writing; `Add` already MkdirAll's its own path independently. Read-only commands (`list`/`show`) on a non-existent system backlog take the existing "no ideas file yet" path — they neither create the dir nor error. + +### Format is path-independent + +Only path resolution changed. The backlog file format, ID rules, escaping, canonical write, and all CRUD/`fmt`/`list`/`show`/JSON semantics are byte-for-byte identical regardless of which path resolved (Constitution I) — the system backlog is the same canonical Markdown checklist at a different location. The behavior contract for external consumers is in `../../specs/overview.md` ("Worktree Behavior" + resolution precedence). + +**Constitution II scope.** Principle II ("worktree-aware by default, all resolution via `git rev-parse`") still governs the in-git case — git resolution remains the default and the only path used when a repo is present and no override is given. 2b3m added a *sanctioned* non-git path (the system backlog) without amending Principle II; the escape hatch is documented in the spec/overview only, a deliberate non-blocking judgment call left at intake. + ## Backlog line lifecycle (lenient read, canonical write) `internal/idea/idea.go` owns the parse/format/save contract for backlog lines. The governing principle is **lenient on read, canonical on write** (be liberal in what you accept, strict in what you emit). This shape was established by `260610-wtmn-resilient-backlog-parser`, which fixed silent-failure parsing of dateless backlogs (e.g. shll.ai's `- [ ] [id] text` form). @@ -202,7 +246,7 @@ This is the single source for the shll.ai command-reference: the `help-dump` sub ## Root command factory -`cmd/idea/main.go` builds the root command through a `newRootCmd() *cobra.Command` factory rather than inline inside `main()`. The factory constructs root (with `Version: version`, the bare-text shorthand `RunE`, and the `--file`/`--main` persistent flags) and registers every subcommand: +`cmd/idea/main.go` builds the root command through a `newRootCmd() *cobra.Command` factory rather than inline inside `main()`. The factory constructs root (with `Version: version`, the bare-text shorthand `RunE`, and the `--file`/`--main`/`--system` persistent flags — see *Backlog path resolution* above for what each selects) and registers every subcommand: ```go root.AddCommand( @@ -217,7 +261,7 @@ The factory exists so the live cobra tree can be constructed in two places off t ## Command aliases and the bare-text shorthand -`list` is the only subcommand with an alias: `Aliases: []string{"ls"}` in the `listCmd()` command literal (`cmd/idea/list.go`), added by `260610-04rt-add-ls-alias`. `idea ls` is identical to `idea list` in every respect — same flags (`--all/-a`, `--done`, `--json`, `--sort`, `--reverse`), same inherited persistent flags (`--file`, `--main`), same output. The alias is pure routing; the `list` command's behavior and JSON output are unchanged. +`list` is the only subcommand with an alias: `Aliases: []string{"ls"}` in the `listCmd()` command literal (`cmd/idea/list.go`), added by `260610-04rt-add-ls-alias`. `idea ls` is identical to `idea list` in every respect — same flags (`--all/-a`, `--done`, `--json`, `--sort`, `--reverse`), same inherited persistent flags (`--file`, `--main`, `--system`), same output. The alias is pure routing; the `list` command's behavior and JSON output are unchanged. **Routing rule (load-bearing).** Cobra resolves subcommand names **and aliases** before the root `RunE` bare-text fallback fires. Two consequences: diff --git a/docs/specs/overview.md b/docs/specs/overview.md index 2338762..a7e277b 100644 --- a/docs/specs/overview.md +++ b/docs/specs/overview.md @@ -24,7 +24,21 @@ The manual installer builds the binary via `./scripts/build.sh` and copies it to By default, `idea` operates on the **current worktree's** `fab/backlog.md` (resolved via `git rev-parse --show-toplevel`). Pass `--main` to target the main worktree's backlog instead; internally, `idea` resolves the main worktree root by running `git rev-parse --path-format=absolute --git-common-dir` and taking its parent directory. In the main worktree, both behave identically. This ensures that users in a linked worktree get predictable local behavior unless they explicitly opt into the shared backlog. -The backlog file path can also be overridden globally by `--file ` (relative to the resolved git root) or by setting the `IDEAS_FILE` environment variable. +The backlog file path can also be overridden globally by `--file ` or by setting the `IDEAS_FILE` environment variable. + +### System backlog and out-of-git operation + +`idea` also works **outside any git repository** and offers a **system-level backlog** for cross-repo idea capture. The system backlog lives at `$XDG_CONFIG_HOME/idea/backlog.md` when `XDG_CONFIG_HOME` is set, and `~/.config/idea/backlog.md` otherwise (resolved via Go's `os.UserConfigDir`). Its parent directory is created on demand on the first mutating write. The file format and all command semantics are identical to a repo backlog — only the path differs. + +The backlog path is resolved by this precedence (first match wins): + +1. **`--system`** — the system backlog, skipping git entirely (reachable from inside a repo too). +2. **`--file ` / `IDEAS_FILE`** — joined to the git root when inside a repo, else to the system config dir (`~/.config/idea/`). An absolute value is used verbatim. +3. **`--main`** — the main worktree root. Git-only: it still errors with `not in a git repository` outside a repo. +4. **In a git repo, no override** — `{worktree-root}/fab/backlog.md` (the default). +5. **Outside a git repo, no override** — the system backlog (the graceful fallback; commands no longer fail with `not in a git repository`). + +`--system` and `--main` are mutually exclusive — passing both is a user error and exits non-zero. ## Commands diff --git a/fab/changes/260613-2b3m-system-level-backlog/.history.jsonl b/fab/changes/260613-2b3m-system-level-backlog/.history.jsonl new file mode 100644 index 0000000..eb334bf --- /dev/null +++ b/fab/changes/260613-2b3m-system-level-backlog/.history.jsonl @@ -0,0 +1,12 @@ +{"action":"enter","driver":"fab-new","event":"stage-transition","stage":"intake","ts":"2026-06-13T07:43:29Z"} +{"args":"Allow idea to work even out of git folders. Use a system level backlog.md to track ideas at a system level","cmd":"fab-new","event":"command","ts":"2026-06-13T07:43:29Z"} +{"delta":"+1.6","event":"confidence","score":1.6,"trigger":"calc-score","ts":"2026-06-13T07:44:30Z"} +{"delta":"+0.0","event":"confidence","score":1.6,"trigger":"calc-score","ts":"2026-06-13T07:44:34Z"} +{"delta":"+1.5","event":"confidence","score":3.1,"trigger":"calc-score","ts":"2026-06-13T07:45:15Z"} +{"delta":"+0.0","event":"confidence","score":3.1,"trigger":"calc-score","ts":"2026-06-13T07:45:20Z"} +{"cmd":"fab-fff","event":"command","ts":"2026-06-13T07:46:40Z"} +{"action":"enter","driver":"fab-fff","event":"stage-transition","stage":"apply","ts":"2026-06-13T07:46:44Z"} +{"action":"enter","driver":"fab-fff","event":"stage-transition","stage":"review","ts":"2026-06-13T07:54:47Z"} +{"action":"enter","driver":"fab-fff","event":"stage-transition","stage":"hydrate","ts":"2026-06-13T07:59:06Z"} +{"event":"review","result":"passed","ts":"2026-06-13T07:59:06Z"} +{"action":"enter","driver":"fab-fff","event":"stage-transition","stage":"ship","ts":"2026-06-13T08:01:31Z"} diff --git a/fab/changes/260613-2b3m-system-level-backlog/.status.yaml b/fab/changes/260613-2b3m-system-level-backlog/.status.yaml new file mode 100644 index 0000000..fee1001 --- /dev/null +++ b/fab/changes/260613-2b3m-system-level-backlog/.status.yaml @@ -0,0 +1,49 @@ +id: 2b3m +name: 260613-2b3m-system-level-backlog +created: 2026-06-13T07:43:29Z +created_by: sahil-noon +change_type: feat +issues: [] +progress: + intake: done + apply: done + review: done + hydrate: done + ship: active + review-pr: pending +plan: + generated: true + task_count: 9 + acceptance_count: 13 + acceptance_completed: 0 +confidence: + certain: 6 + confident: 3 + tentative: 1 + unresolved: 0 + score: 3.1 + fuzzy: true + dimensions: + signal: 74.8 + reversibility: 76.0 + competence: 80.5 + disambiguation: 80.0 +stage_metrics: + intake: {started_at: "2026-06-13T07:43:29Z", driver: fab-new, iterations: 1, completed_at: "2026-06-13T07:46:44Z"} + apply: {started_at: "2026-06-13T07:46:44Z", driver: fab-fff, iterations: 1, completed_at: "2026-06-13T07:54:47Z"} + review: {started_at: "2026-06-13T07:54:47Z", driver: fab-fff, iterations: 1, completed_at: "2026-06-13T07:59:06Z"} + hydrate: {started_at: "2026-06-13T07:59:06Z", driver: fab-fff, iterations: 1, completed_at: "2026-06-13T08:01:31Z"} + ship: {started_at: "2026-06-13T08:01:31Z", driver: fab-fff, iterations: 1} +prs: [] +true_impact: + added: 0 + deleted: 0 + net: 0 + excluding: + added: 0 + deleted: 0 + net: 0 + computed_at: "2026-06-13T08:01:31Z" + computed_at_stage: hydrate +# true_impact: lazily created on first apply-finish (no placeholder here). +last_updated: 2026-06-13T08:01:31Z diff --git a/fab/changes/260613-2b3m-system-level-backlog/intake.md b/fab/changes/260613-2b3m-system-level-backlog/intake.md new file mode 100644 index 0000000..5782df2 --- /dev/null +++ b/fab/changes/260613-2b3m-system-level-backlog/intake.md @@ -0,0 +1,117 @@ +# Intake: System-Level Backlog & Out-of-Git Operation + +**Change**: 260613-2b3m-system-level-backlog +**Created**: 2026-06-13 + +## Origin + +> Allow idea to work even out of git folders. Use a system level backlog.md to track ideas at a system level + +Conversational mode. The raw input was a two-part request: (1) make `idea` usable outside a git repository, and (2) introduce a system-level backlog for cross-repo idea capture. The interaction surfaced design tension with **Constitution Principle II** (worktree-aware by default, all resolution via `git rev-parse`) — operating outside git requires a sanctioned non-git path. Three decisions were resolved interactively: + +- **Out-of-git default behavior** — user chose *graceful fallback to the system backlog* (over CWD-local `./fab/backlog.md` or erroring with a required flag). +- **Explicit `--system` flag** — user chose *yes, add it* as a persistent peer of `--main`, so the system backlog is reachable from inside a repo too (not implicit-only). +- **System file location** — user chose `~/.config/idea/backlog.md`, explicitly referencing "hop style" (`~/.config/hop/hop.yaml`). hop uses the plain `~/.config` location, which *is* the XDG default; we therefore honor `$XDG_CONFIG_HOME` when set and fall back to `~/.config/idea/backlog.md`. + +## Why + +1. **Problem**: Today every `idea` command shells out to `git rev-parse --show-toplevel` (or `--git-common-dir`) to find a repo root, and joins the backlog path to it. Outside a git repo both helpers return `not in a git repository`, so **every command fails** — `--file` and `IDEAS_FILE` don't rescue this, because their values are still joined to the (failed) git-resolved root. There is also no place to capture an idea that isn't tied to a specific repo (a cross-cutting task, a personal todo, a "remember to look at X" note). +2. **Consequence if unfixed**: `idea` is unusable in `~`, `/tmp`, plain notes folders, or any non-repo directory — exactly where lightweight idea capture is most natural. Cross-repo ideas have nowhere to live but get wedged into whichever repo happened to be the CWD. +3. **Why this approach**: A single, fixed system-level backlog (XDG-located, mirroring hop) is the lowest-friction model: "outside git ⇒ your personal global list" is predictable and never scatters `fab/` folders across arbitrary directories. The explicit `--system` flag makes the same target reachable from inside a repo without forcing a `cd`. This extends the existing flag-driven resolution model (`--main`, `--file`) rather than inventing a new one, and keeps Principle II intact for the in-git case (git resolution is still the default and the only path used when a repo is present and no override is given). + +## What Changes + +### 1. Path resolution gains a non-git fallback + +`resolveFile()` (in `src/cmd/idea/resolve.go`) currently fails hard when `git rev-parse` errors. New resolution precedence: + +1. `--system` flag set → **system backlog** (skip git entirely). +2. `--file ` set → joined to the resolved root (git root if in a repo; **system config dir** if outside git — so an out-of-git `--file rel/path` resolves under `~/.config/idea/`). +3. `IDEAS_FILE` env set → same rooting rule as `--file`. +4. `--main` set → main worktree root (requires git; unchanged — errors outside git as today). +5. In a git repo, no override → `{worktree-root}/fab/backlog.md` (**unchanged default**). +6. **Outside a git repo, no override → system backlog** (the new graceful fallback). + +### 2. New `--system` persistent flag + +Defined on root alongside `--main`: + +```go +root.PersistentFlags().BoolVar(&systemFlag, "system", false, "Operate on the system-level backlog (~/.config/idea/backlog.md) instead of a repo backlog") +``` + +Forces the system backlog from anywhere, including inside a repo: + +``` +$ cd ~/my-repo && idea --system "global todo" # -> ~/.config/idea/backlog.md +$ idea --system list # lists the system backlog +``` + +`--system` and `--main` are mutually exclusive (both pick a root; specifying both is a user error → clear error message, non-zero exit). + +### 3. System backlog location helper + +A new `internal/idea` function resolves the system backlog path: + +```go +// SystemBacklogPath returns the system-level backlog file path: +// $XDG_CONFIG_HOME/idea/backlog.md (when XDG_CONFIG_HOME is set) +// ~/.config/idea/backlog.md (otherwise) +func SystemBacklogPath() (string, error) +``` + +Mirrors hop's convention (`~/.config/hop/hop.yaml`). The parent directory (`~/.config/idea/`) is **created on demand** when a mutating command needs to write and the file/dir does not yet exist (consistent with how the tool expects to write a fresh backlog). + +### 4. Behavior summary + +``` +$ cd /tmp && idea "buy milk" # outside git -> ~/.config/idea/backlog.md +$ cd ~/my-repo && idea "fix bug" # in git -> ~/my-repo/fab/backlog.md (unchanged) +$ cd ~/my-repo && idea --system "x" # in git -> ~/.config/idea/backlog.md +$ idea --system list # anywhere -> ~/.config/idea/backlog.md +$ idea --main "y" # outside git -> ERROR: not in a git repository (unchanged) +``` + +The backlog **file format is entirely unchanged** — the system backlog is the same canonical Markdown checklist, just at a different path. All CRUD/`fmt`/`list`/`show` semantics carry over verbatim. + +### 5. Docs / help text + +Per-command `Long` help and `docs/specs/overview.md` "Worktree Behavior" section gain a "system backlog" note; the resolution-precedence change is documented in `overview.md`. + +## Affected Memory + +- `cli/structure`: (modify) Document the path-resolution precedence (system fallback when outside git, `--system` flag, `~/.config/idea/backlog.md` XDG location) alongside the existing worktree-resolution notes. + +## Impact + +- **Code**: + - `src/cmd/idea/resolve.go` — resolution precedence rewrite (the core change). + - `src/cmd/idea/main.go` — register `--system` persistent flag; `--system`/`--main` conflict check. + - `src/internal/idea/idea.go` — add `SystemBacklogPath()`; adjust `ResolveFilePath` rooting for the out-of-git `--file`/`IDEAS_FILE` case; on-demand dir creation on write. + - Per-command `Long` help strings (`add.go`, `list.go`, `show.go`, `done.go`, `reopen.go`, `edit.go`, `rm.go`, `prune.go`, `fmt.go`). + - `help_dump.go` output picks up the new flag automatically (walks the live tree). +- **Tests**: `t.TempDir()`-based tests for: outside-git fallback (point `$HOME`/`$XDG_CONFIG_HOME` at a temp dir), `--system` inside a repo, `--system`+`--main` conflict, on-demand dir creation. Git-dependent cases use a real temp repo per Constitution V. +- **Specs**: `docs/specs/overview.md` (Worktree Behavior + resolution precedence). +- **Constitution**: Principle II ("Worktree-Aware by Default, Main-Worktree Opt-In") narrows in scope — it still governs the in-git case, but a sanctioned non-git path now exists. May warrant a one-line amendment noting the system-backlog escape hatch. +- **No new dependencies** — uses `os.UserConfigDir`/stdlib only (stays within stdlib + cobra per Dependency Discipline). + +## Open Questions + +- Should the constitution's Principle II be formally amended to mention the system-backlog escape hatch, or is the spec/overview note sufficient? (Flagged in Impact; non-blocking.) + +## Assumptions + +| # | Grade | Decision | Rationale | Scores | +|---|-------|----------|-----------|--------| +| 1 | Certain | Outside git, the default backlog is the system-level file (graceful fallback, no error) | Decided by the user in conversation — system-level chosen over CWD-local and required-flag; no remaining alternative | S:90 R:80 A:90 D:90 | +| 2 | Certain | Add a persistent `--system` flag (peer of `--main`) forcing the system backlog from anywhere | Decided by the user in conversation — "yes, add --system" over implicit-only; mirrors the existing `--main` pattern exactly | S:90 R:80 A:90 D:90 | +| 3 | Certain | System backlog lives at `$XDG_CONFIG_HOME/idea/backlog.md`, default `~/.config/idea/backlog.md` | Decided by the user — specified `~/.config/idea/backlog.md`, "hop style"; XDG-respect is the standard reading and verified against hop's own layout | S:88 R:75 A:88 D:85 | +| 4 | Confident | `--system` and `--main` together are a conflict error (non-zero exit), not silent precedence | Two explicit root selectors disagreeing should fail loudly; config gives no signal for a silent winner; cheap to change | S:55 R:75 A:65 D:70 | +| 5 | Confident | Out-of-git `--file`/`IDEAS_FILE` relative values root at the system config dir, not CWD | Keeps a single consistent non-git anchor; absolute paths are unaffected; reversible if it surprises users | S:55 R:70 A:65 D:65 | +| 6 | Certain | `--main` stays git-only (errors outside a repo, unchanged) | Constitution II + the definition of a worktree fix this — `--main` has no coherent out-of-git meaning; behavior is unchanged | S:85 R:80 A:92 D:90 | +| 7 | Confident | The system config dir is created on demand (`mkdir -p`) on first mutating write | Matches the zero-setup capture goal; erroring "no such directory" would defeat the feature. Trivially reversible | S:60 R:80 A:70 D:75 | +| 8 | Certain | File format, ID rules, and all CRUD/`fmt` semantics are unchanged — only the path differs | Constitution I + backlog-format spec fix the format; this change touches resolution only | S:90 R:75 A:95 D:95 | +| 9 | Certain | No new dependencies; use `os.UserConfigDir` / stdlib for the XDG path | Dependency Discipline (stdlib + cobra only) decides it; stdlib `UserConfigDir` already covers the XDG path | S:85 R:80 A:95 D:90 | +| 10 | Tentative | Constitution Principle II gets a one-line note about the system-backlog escape hatch; spec/overview updated regardless | Reasonable to keep the constitution accurate, but whether to amend vs. spec-note-only is a judgment call — flagged, non-blocking | S:50 R:65 A:55 D:50 | + +10 assumptions (6 certain, 3 confident, 1 tentative, 0 unresolved). diff --git a/fab/changes/260613-2b3m-system-level-backlog/plan.md b/fab/changes/260613-2b3m-system-level-backlog/plan.md new file mode 100644 index 0000000..3178678 --- /dev/null +++ b/fab/changes/260613-2b3m-system-level-backlog/plan.md @@ -0,0 +1,168 @@ +# Plan: System-Level Backlog & Out-of-Git Operation + +**Change**: 260613-2b3m-system-level-backlog +**Intake**: `intake.md` + +## Requirements + +### Path Resolution: Out-of-Git Fallback & System Backlog + +#### R1: System backlog path helper +`internal/idea` SHALL expose a `SystemBacklogPath() (string, error)` function that returns the system-level backlog file path. It MUST honor `$XDG_CONFIG_HOME` when set (`$XDG_CONFIG_HOME/idea/backlog.md`) and otherwise fall back to `~/.config/idea/backlog.md`, using Go stdlib `os.UserConfigDir` (no new dependencies). + +- **GIVEN** `XDG_CONFIG_HOME` is set to `/custom/cfg` +- **WHEN** `SystemBacklogPath()` is called +- **THEN** it returns `/custom/cfg/idea/backlog.md` +- **AND GIVEN** `XDG_CONFIG_HOME` is unset and `HOME` is `/home/u` +- **WHEN** `SystemBacklogPath()` is called +- **THEN** it returns `/home/u/.config/idea/backlog.md` + +#### R2: Resolution precedence +`resolveFile()` (in `cmd/idea`) SHALL resolve the backlog path by this precedence (first match wins): (1) `--system` flag → system backlog, skipping git entirely; (2) `--file ` / `IDEAS_FILE` env → joined to the git root when in a repo, else to the system config dir; (3) `--main` → main worktree root (git-only, errors outside git, UNCHANGED); (4) in a git repo with no override → `{worktree-root}/fab/backlog.md` (UNCHANGED default); (5) outside a git repo with no override → system backlog (the NEW graceful fallback). + +- **GIVEN** the CWD is not inside any git repository and no flags/env are set +- **WHEN** any backlog command runs +- **THEN** it operates on `SystemBacklogPath()` instead of failing with "not in a git repository" +- **AND GIVEN** the CWD is inside a git repo with no override +- **WHEN** a command runs +- **THEN** it resolves to `{worktree-root}/fab/backlog.md` exactly as before + +#### R3: `--system` persistent flag +The root command SHALL define a persistent `--system` boolean flag (peer of `--main`). When set, every command operates on the system backlog regardless of CWD (including inside a git repo). + +- **GIVEN** the CWD is inside a git repo +- **WHEN** `idea --system "global todo"` runs +- **THEN** the idea is written to the system backlog, not the repo backlog +- **AND GIVEN** any directory +- **WHEN** `idea --system list` runs +- **THEN** it lists the system backlog + +#### R4: `--system` / `--main` conflict +Specifying both `--system` and `--main` SHALL be a user error: the command MUST print a clear error and exit non-zero, performing no backlog operation. + +- **GIVEN** both `--system` and `--main` are passed +- **WHEN** any command runs +- **THEN** it exits non-zero with a message naming the conflict and writes nothing to any backlog + +#### R5: Out-of-git `--file` / `IDEAS_FILE` rooting +When outside a git repo, a relative `--file`/`IDEAS_FILE` value SHALL be joined to the system config dir (`$XDG_CONFIG_HOME/idea` or `~/.config/idea`); an absolute value SHALL be used as-is. Inside a git repo the rooting is unchanged (joined to the git root). + +- **GIVEN** the CWD is outside git and `--file notes.md` is passed +- **WHEN** a command runs +- **THEN** the path resolves to `{config-dir}/idea/notes.md` +- **AND GIVEN** `--file /abs/path.md` is passed (in or out of git) +- **THEN** the path resolves to `/abs/path.md` + +#### R6: On-demand config dir creation +The system config dir (`~/.config/idea/`) SHALL be created on demand (`mkdir -p` semantics) on the first mutating write, rather than erroring "no such directory". Read-only commands on a non-existent system backlog behave like read-only commands on any missing file (no error beyond the existing "no ideas file yet" path). + +- **GIVEN** `~/.config/idea/` does not exist +- **WHEN** `idea --system "first idea"` runs +- **THEN** the directory is created and the idea is written + +#### R7: `--main` stays git-only (unchanged) +`--main` SHALL continue to require a git repo and error with "not in a git repository" when run outside one. Its behavior is entirely unchanged. + +- **GIVEN** the CWD is outside any git repo +- **WHEN** `idea --main "x"` runs +- **THEN** it errors with "not in a git repository" and exits non-zero + +#### R8: File format and CRUD/fmt semantics unchanged +The backlog file format, ID rules, and all CRUD/`fmt`/`list`/`show` semantics SHALL be identical regardless of which path was resolved. Only path resolution changes. + +- **GIVEN** the system backlog is the resolved path +- **WHEN** any command operates on it +- **THEN** the line format, escaping, canonical-write, and JSON output are byte-for-byte the same as for a repo backlog + +### Non-Goals + +- Changing the backlog line format or any CRUD/fmt behavior — only path resolution changes. +- A per-directory (`./fab/backlog.md` in CWD) out-of-git mode — rejected at intake in favor of a single system backlog. +- Auto-creating the system dir on read-only commands — creation is write-triggered only. + +### Design Decisions + +1. **Out-of-git default is the system backlog**: graceful fallback over erroring or CWD-local. — *Why*: zero-friction capture anywhere; predictable single global list. — *Rejected*: CWD-local `./fab/backlog.md` (scatters fab/ folders); required-flag erroring (defeats the goal). +2. **`--system` is a persistent flag, peer of `--main`**: reachable from inside a repo. — *Why*: mirrors the existing `--main` pattern; no `cd` required. — *Rejected*: implicit-only out-of-git system backlog. +3. **`--system` + `--main` is a hard conflict error**: two explicit root selectors disagreeing fail loudly. — *Why*: no signal for a silent winner. — *Rejected*: silent precedence. +4. **Out-of-git relative `--file`/`IDEAS_FILE` roots at the system config dir**: one consistent non-git anchor. — *Why*: keeps a single anchor coherent with the system-backlog model. — *Rejected*: rooting at CWD. +5. **Resolution centralized in `internal/idea`**: `resolveFile()` in `cmd/` only wires flags. — *Why*: Constitution IV (logic in `internal/idea`). The new `ResolveBacklogPath` helper owns precedence; `cmd/` passes flag values in. + +## Tasks + +### Phase 1: Core Implementation (internal/idea) + +- [x] T001 Add `SystemBacklogPath() (string, error)` to `src/internal/idea/idea.go` using `os.UserConfigDir` (honors `XDG_CONFIG_HOME`, falls back to `~/.config`), returning `{configDir}/idea/backlog.md`. +- [x] T002 Add a centralized resolution helper to `src/internal/idea/idea.go` — `ResolveBacklogPath(systemFlag, mainFlag bool, fileFlag string) (string, error)` — encoding the full precedence (system → file/env rooted at git-root-or-config-dir → main → in-git default → out-of-git system fallback), including the `--system`/`--main` conflict error and the out-of-git `--file`/`IDEAS_FILE` rooting at the config dir. Absolute `--file`/`IDEAS_FILE` values bypass rooting. +- [x] T003 Ensure on-demand config-dir creation on the first mutating write: add `os.MkdirAll(filepath.Dir(path), ...)` to `atomicWriteFile` in `src/internal/idea/idea.go` so SaveFile-based mutations (done/reopen/edit/rm/prune/fmt) create a missing system dir; `Add` already does this. + +### Phase 2: Flag Wiring (cmd/idea) + +- [x] T004 Register a `--system` persistent bool flag on root in `src/cmd/idea/main.go` (peer of `--main`); update the `--file` flag description to note out-of-git rooting at the config dir. +- [x] T005 Rewrite `resolveFile()` in `src/cmd/idea/resolve.go` to delegate to `idea.ResolveBacklogPath(systemFlag, mainFlag, fileFlag)`, keeping `cmd/` free of resolution logic (Constitution IV). + +### Phase 3: Tests + +- [x] T006 [P] Add table-driven unit tests in `src/internal/idea/idea_test.go` for `SystemBacklogPath` (XDG set vs. unset via `t.Setenv`) and `ResolveBacklogPath` (all precedence branches incl. conflict error, in-git vs out-of-git file rooting, absolute path bypass). Use `t.TempDir()` + `t.Setenv` for HOME/XDG; gate git-root cases behind a real temp repo or inject the root. +- [x] T007 Add integration tests in `src/cmd/idea/main_test.go`: out-of-git add+list falls back to the system backlog (run binary from a non-git temp dir with HOME/XDG_CONFIG_HOME set via `cmd.Env`); `--system` inside a repo targets the system backlog not the repo backlog; `--system --main` conflict exits non-zero; on-demand dir creation (config dir absent before first `--system add`). + +### Phase 4: Docs & Help + +- [x] T008 Update per-command `Long` help strings (`add.go`, `list.go`, `show.go`, `done.go`, `reopen.go`, `edit.go`, `rm.go`, `prune.go`, `fmt.go` in `src/cmd/idea/`) to mention `--system` / the system backlog alongside the existing `--main` / `--file` note. +- [x] T009 [P] Update `docs/specs/overview.md` "Worktree Behavior" section to document the resolution precedence, the `--system` flag, the out-of-git system fallback, and the `~/.config/idea/backlog.md` XDG location. + +## Execution Order + +- T001 → T002 (resolution helper uses SystemBacklogPath) +- T002, T003 → T005 (resolveFile delegates to the new helper) +- T004 (flag declaration) → T005, T007 (need the flag var) +- T006 depends on T001, T002; T007 depends on T004, T005, T003 +- T008, T009 are independent docs tasks (parallelizable) + +## Acceptance + +### Functional Completeness + +- [ ] A-001 R1: `SystemBacklogPath()` returns `$XDG_CONFIG_HOME/idea/backlog.md` when set and `~/.config/idea/backlog.md` otherwise, via `os.UserConfigDir`. +- [ ] A-002 R2: `resolveFile()`/`ResolveBacklogPath` implements the full first-match-wins precedence (system, file/env, main, in-git default, out-of-git fallback). +- [ ] A-003 R3: A `--system` persistent flag exists on root and forces the system backlog from anywhere, including inside a repo. +- [ ] A-004 R6: The system config dir is created on demand on the first mutating write. +- [ ] A-005 R7: `--main` still requires git and errors "not in a git repository" outside a repo. + +### Behavioral Correctness + +- [ ] A-006 R2: Inside a git repo with no override, resolution is unchanged (`{worktree-root}/fab/backlog.md`). +- [ ] A-007 R5: Out-of-git relative `--file`/`IDEAS_FILE` roots at the config dir; absolute values pass through unchanged. + +### Edge Cases & Error Handling + +- [ ] A-008 R4: `--system --main` together exits non-zero with a clear conflict message and writes nothing. +- [ ] A-009 R2: Outside git with no override, commands no longer fail with "not in a git repository" — they use the system backlog. + +### Scenario Coverage + +- [ ] A-010 R3 R6 R8: Integration tests cover out-of-git fallback, `--system` inside a repo, the conflict error, and on-demand dir creation; format/CRUD on the system path is identical to a repo path. + +### Code Quality + +- [ ] A-011 Pattern consistency: New code follows surrounding naming and structural patterns; resolution logic lives in `internal/idea`, only flag wiring in `cmd/` (Constitution IV); git resolution still uses `git rev-parse` (Constitution II). +- [ ] A-012 No unnecessary duplication: Reuses `WorktreeRoot`/`MainRepoRoot`/`ResolveFilePath`/`os.UserConfigDir`/`atomicWriteFile` rather than reimplementing; no new dependencies (stdlib + cobra only). +- [ ] A-013 Magic strings: The `idea`/`backlog.md`/`.config` path segments are introduced consistently without scattering unnamed literals where a named helper is clearer. + +## Notes + +- Check items as you review: `- [x]` +- All acceptance items must pass before `/fab-continue` (hydrate) +- If an item is not applicable, mark checked and prefix with **N/A**: `- [x] A-NNN **N/A**: {reason}` + +## Assumptions + +| # | Grade | Decision | Rationale | Scores | +|---|-------|----------|-----------|--------| +| 1 | Confident | Precedence centralized in a new `internal/idea.ResolveBacklogPath(systemFlag, mainFlag, fileFlag)` helper; `resolveFile()` in `cmd/` only forwards flag values | Constitution IV mandates logic in `internal/idea`; the existing `resolveFile` already orchestrated, so this is a natural extension. Cheap to reshape if review prefers a different signature | S:70 R:75 A:80 D:70 | +| 2 | Confident | On-demand dir creation is added to `atomicWriteFile` (the single SaveFile serialization point); `Add` already MkdirAll's its own path | Single seam covers all SaveFile-based mutations without scattering MkdirAll across each op; matches the existing `Add` precedent. Reversible | S:65 R:80 A:75 D:75 | +| 3 | Confident | The `--system`/`--main` conflict is detected inside the resolution helper and returned as an error (surfaced via the existing `ERROR:` top-level handler), not via a separate cobra PreRun | Keeps the conflict check colocated with the precedence it guards; reuses the existing non-zero-exit error path. Easy to move to a PreRunE if review prefers earlier rejection | S:60 R:75 A:70 D:65 | +| 4 | Confident | Out-of-git tests drive the built binary with `cmd.Env` setting HOME/XDG_CONFIG_HOME and run from a non-git temp dir; unit tests use `t.Setenv` | Constitution V (real temp dirs, no FS mocks) + the existing `buildBinary`/`setupGitRepo` integration-test pattern; `exec.Command` needs explicit `cmd.Env` to isolate HOME/XDG | S:70 R:80 A:80 D:75 | +| 5 | Confident | Constitution Principle II is NOT amended in this change (spec/overview note only); flagged non-blocking at intake | Intake assumption 10 left this a judgment call and explicitly non-blocking; the change does not violate II (git resolution stays the in-repo default). Trivially revisited later | S:55 R:70 A:60 D:60 | + +5 assumptions (0 certain, 5 confident, 0 tentative). diff --git a/src/cmd/idea/add.go b/src/cmd/idea/add.go index 308ed4b..13130f3 100644 --- a/src/cmd/idea/add.go +++ b/src/cmd/idea/add.go @@ -18,8 +18,10 @@ func addCmd() *cobra.Command { The idea is appended as a Markdown checklist line with a generated 4-char ID and today's date. Use --id and --date to override those generated values (handy when importing or backdating). By default the command writes the -current worktree's backlog; --main targets the main worktree's backlog, and ---file / IDEAS_FILE point at a different file (see "idea --help"). +current worktree's backlog; --main targets the main worktree's backlog, +--system targets the system-level backlog (~/.config/idea/backlog.md), and +--file / IDEAS_FILE point at a different file (see "idea --help"). Outside a +git repo the system backlog is used automatically. idea add "wire up dark mode" idea add --id a7k2 --date 2026-06-01 "backdated idea"`, diff --git a/src/cmd/idea/done.go b/src/cmd/idea/done.go index 3545764..eb9efbe 100644 --- a/src/cmd/idea/done.go +++ b/src/cmd/idea/done.go @@ -16,8 +16,10 @@ func doneCmd() *cobra.Command { matches an open idea by its ID or by a case-insensitive substring of its text. If the query matches more than one open idea it is refused and the ambiguous matches are listed, so you can be more specific or use the exact ID. ---main targets the main worktree's backlog and --file / IDEAS_FILE point -elsewhere (see "idea --help"). +--main targets the main worktree's backlog, --system targets the system-level +backlog (~/.config/idea/backlog.md), and --file / IDEAS_FILE point elsewhere +(see "idea --help"). Outside a git repo the system backlog is used +automatically. idea done a7k2 idea done "dark mode"`, diff --git a/src/cmd/idea/edit.go b/src/cmd/idea/edit.go index 1d9f5c9..6f3ee32 100644 --- a/src/cmd/idea/edit.go +++ b/src/cmd/idea/edit.go @@ -26,8 +26,9 @@ refused, and a non-zero editor exit aborts without touching the backlog. substring of its text. If it matches more than one idea it is refused and the ambiguous matches are listed, so you can be more specific or use the exact ID. --id and --date additionally change the matched idea's ID or date. --main -targets the main worktree's backlog and --file / IDEAS_FILE point elsewhere -(see "idea --help"). +targets the main worktree's backlog, --system targets the system-level backlog +(~/.config/idea/backlog.md), and --file / IDEAS_FILE point elsewhere (see +"idea --help"). Outside a git repo the system backlog is used automatically. idea edit a7k2 "wire up dark mode toggle" idea edit a7k2 # open the current text in your editor diff --git a/src/cmd/idea/fmt.go b/src/cmd/idea/fmt.go index 4dac898..acd7f38 100644 --- a/src/cmd/idea/fmt.go +++ b/src/cmd/idea/fmt.go @@ -28,8 +28,9 @@ byte-stable, and an already-canonical file is not rewritten at all. stdout stays empty; the report (one "adopted:" line per adopted idea plus summary counts) goes to stderr. --check writes nothing, prints the same report, and exits 1 when the file would change, 0 when it is already canonical. --main -targets the main worktree's backlog and --file / IDEAS_FILE point elsewhere -(see "idea --help"). +targets the main worktree's backlog, --system targets the system-level backlog +(~/.config/idea/backlog.md), and --file / IDEAS_FILE point elsewhere (see +"idea --help"). Outside a git repo the system backlog is used automatically. idea fmt idea fmt --check`, diff --git a/src/cmd/idea/list.go b/src/cmd/idea/list.go index b660e37..e453e8b 100644 --- a/src/cmd/idea/list.go +++ b/src/cmd/idea/list.go @@ -29,8 +29,10 @@ On a terminal, long idea text is truncated to fit the width (the [id] date: prefix is never clipped) and the prefix is dimmed; --full shows the complete text. When the output is piped or redirected, full canonical lines are emitted regardless of --full so downstream tools see machine-parseable records. As with -every backlog command, --main targets the main worktree's backlog and ---file / IDEAS_FILE point elsewhere (see "idea --help"). +every backlog command, --main targets the main worktree's backlog, --system +targets the system-level backlog ($XDG_CONFIG_HOME/idea/backlog.md, else +~/.config/idea/backlog.md), and --file / IDEAS_FILE point elsewhere (see +"idea --help"). Outside a git repo the system backlog is used automatically. idea list idea list --all --sort id diff --git a/src/cmd/idea/main.go b/src/cmd/idea/main.go index fa5c05f..4966e90 100644 --- a/src/cmd/idea/main.go +++ b/src/cmd/idea/main.go @@ -11,6 +11,7 @@ import ( var fileFlag string var mainFlag bool +var systemFlag bool // version is the binary version, overridden via -ldflags "-X main.version=..." at build time. var version = "dev" @@ -48,8 +49,9 @@ Shorthand: "idea " is equivalent to "idea add ".`, }, } - root.PersistentFlags().StringVar(&fileFlag, "file", "", "Override backlog file path (relative to git root)") + root.PersistentFlags().StringVar(&fileFlag, "file", "", "Override backlog file path (relative to the git root, or to ~/.config/idea when outside a repo)") root.PersistentFlags().BoolVar(&mainFlag, "main", false, "Operate on the main worktree's backlog instead of the current worktree") + root.PersistentFlags().BoolVar(&systemFlag, "system", false, "Operate on the system-level backlog (~/.config/idea/backlog.md) instead of a repo backlog") root.AddCommand( addCmd(), diff --git a/src/cmd/idea/main_test.go b/src/cmd/idea/main_test.go index c4bd895..0430ac1 100644 --- a/src/cmd/idea/main_test.go +++ b/src/cmd/idea/main_test.go @@ -997,3 +997,115 @@ func TestPrune_ConfirmedDeleteAndAbort(t *testing.T) { }) } } + +// --- System Backlog & Out-of-Git Operation --- + +// systemEnv builds a minimal environment that isolates HOME/XDG_CONFIG_HOME at +// the given config dir while preserving PATH (git lookups inside the binary +// need it). Returns the env slice and the resolved system backlog path. +func systemEnv(t *testing.T) (env []string, configDir, backlogPath string) { + t.Helper() + configDir = t.TempDir() + env = []string{ + "HOME=" + configDir, + "XDG_CONFIG_HOME=" + configDir, + "PATH=" + os.Getenv("PATH"), + } + backlogPath = filepath.Join(configDir, "idea", "backlog.md") + return env, configDir, backlogPath +} + +// TestSystem_OutOfGitFallback verifies that, outside any git repo, add/list +// gracefully fall back to the system backlog instead of failing with +// "not in a git repository". +func TestSystem_OutOfGitFallback(t *testing.T) { + bin := buildBinary(t) + nonGit := t.TempDir() // not a git repo + env, _, backlogPath := systemEnv(t) + + stdout, stderr, err := runSplitEnv(t, bin, nonGit, env, "add", "buy milk") + if err != nil { + t.Fatalf("add outside git failed: %v\nstdout=%q stderr=%q", err, stdout, stderr) + } + if !strings.Contains(stdout, "buy milk") { + t.Errorf("expected idea text in output, got: %q", stdout) + } + b, readErr := os.ReadFile(backlogPath) + if readErr != nil { + t.Fatalf("system backlog not written at %s: %v", backlogPath, readErr) + } + if !strings.Contains(string(b), "buy milk") { + t.Errorf("system backlog missing idea, got: %q", b) + } + + // list outside git reads back the same system backlog. + stdout, stderr, err = runSplitEnv(t, bin, nonGit, env, "list") + if err != nil { + t.Fatalf("list outside git failed: %v\nstdout=%q stderr=%q", err, stdout, stderr) + } + if !strings.Contains(stdout, "buy milk") { + t.Errorf("list outside git missing idea, got: %q", stdout) + } +} + +// TestSystem_FlagInsideRepo verifies that --system targets the system backlog +// even when run inside a git repo, leaving the repo backlog untouched. +func TestSystem_FlagInsideRepo(t *testing.T) { + bin := buildBinary(t) + repo := setupGitRepo(t) + env, _, backlogPath := systemEnv(t) + + stdout, stderr, err := runSplitEnv(t, bin, repo, env, "--system", "global todo") + if err != nil { + t.Fatalf("--system add in repo failed: %v\nstdout=%q stderr=%q", err, stdout, stderr) + } + + // The system backlog gets the idea... + b, readErr := os.ReadFile(backlogPath) + if readErr != nil { + t.Fatalf("system backlog not written at %s: %v", backlogPath, readErr) + } + if !strings.Contains(string(b), "global todo") { + t.Errorf("system backlog missing idea, got: %q", b) + } + // ...and the repo backlog stays empty of it (only the seed header). + if repoContent := readRepoBacklog(t, repo); strings.Contains(repoContent, "global todo") { + t.Errorf("repo backlog should not contain the --system idea, got: %q", repoContent) + } +} + +// TestSystem_ConflictWithMain verifies --system + --main is a user error. +func TestSystem_ConflictWithMain(t *testing.T) { + bin := buildBinary(t) + repo := setupGitRepo(t) + env, _, _ := systemEnv(t) + + stdout, stderr, err := runSplitEnv(t, bin, repo, env, "--system", "--main", "x") + if err == nil { + t.Fatalf("expected non-zero exit for --system --main, got success\nstdout=%q", stdout) + } + if !strings.Contains(stderr, "mutually exclusive") { + t.Errorf("expected conflict message on stderr, got: %q", stderr) + } +} + +// TestSystem_OnDemandDirCreation verifies the config dir is created on the +// first mutating write when it does not yet exist. +func TestSystem_OnDemandDirCreation(t *testing.T) { + bin := buildBinary(t) + nonGit := t.TempDir() + env, configDir, backlogPath := systemEnv(t) + + // Precondition: the idea config dir does not exist yet. + ideaDir := filepath.Join(configDir, "idea") + if _, err := os.Stat(ideaDir); !os.IsNotExist(err) { + t.Fatalf("precondition: %s should not exist, stat err=%v", ideaDir, err) + } + + if _, stderr, err := runSplitEnv(t, bin, nonGit, env, "add", "first idea"); err != nil { + t.Fatalf("first add failed: %v\nstderr=%q", err, stderr) + } + if _, err := os.Stat(backlogPath); err != nil { + t.Fatalf("system backlog not created on first write: %v", err) + } +} diff --git a/src/cmd/idea/prune.go b/src/cmd/idea/prune.go index 5d397c0..7247ed2 100644 --- a/src/cmd/idea/prune.go +++ b/src/cmd/idea/prune.go @@ -25,8 +25,10 @@ on stderr) and never prompts. --force skips the prompt and deletes immediately, printing only a count. There is no archive — the backlog is committed, so git history is the recovery path. Long idea text is truncated to the terminal width (--full shows it in full); piped output is always full and machine-parseable. ---main targets the main worktree's backlog, and --file / IDEAS_FILE point -elsewhere (see "idea --help"). +--main targets the main worktree's backlog, --system targets the system-level +backlog ($XDG_CONFIG_HOME/idea/backlog.md, else ~/.config/idea/backlog.md), and +--file / IDEAS_FILE point elsewhere (see "idea --help"). Outside a git repo the +system backlog is used automatically. idea prune idea prune --force`, diff --git a/src/cmd/idea/reopen.go b/src/cmd/idea/reopen.go index 747987b..1ed5c9f 100644 --- a/src/cmd/idea/reopen.go +++ b/src/cmd/idea/reopen.go @@ -16,8 +16,10 @@ func reopenCmd() *cobra.Command { matches a done idea by its ID or by a case-insensitive substring of its text. If the query matches more than one done idea it is refused and the ambiguous matches are listed, so you can be more specific or use the exact ID. ---main targets the main worktree's backlog and --file / IDEAS_FILE point -elsewhere (see "idea --help"). +--main targets the main worktree's backlog, --system targets the system-level +backlog (~/.config/idea/backlog.md), and --file / IDEAS_FILE point elsewhere +(see "idea --help"). Outside a git repo the system backlog is used +automatically. idea reopen a7k2 idea reopen "dark mode"`, diff --git a/src/cmd/idea/resolve.go b/src/cmd/idea/resolve.go index 271bf71..679979d 100644 --- a/src/cmd/idea/resolve.go +++ b/src/cmd/idea/resolve.go @@ -2,16 +2,9 @@ package main import "github.com/sahil87/idea/internal/idea" +// resolveFile wires the persistent flags into the resolution precedence, which +// lives in internal/idea (Constitution IV — cmd/ holds only flag wiring). The +// --system/--main conflict and the out-of-git fallback are decided there. func resolveFile() (string, error) { - var repoRoot string - var err error - if mainFlag { - repoRoot, err = idea.MainRepoRoot() - } else { - repoRoot, err = idea.WorktreeRoot() - } - if err != nil { - return "", err - } - return idea.ResolveFilePath(repoRoot, fileFlag), nil + return idea.ResolveBacklogPath(systemFlag, mainFlag, fileFlag) } diff --git a/src/cmd/idea/rm.go b/src/cmd/idea/rm.go index 7a9702f..ac73324 100644 --- a/src/cmd/idea/rm.go +++ b/src/cmd/idea/rm.go @@ -19,8 +19,10 @@ func rmCmd() *cobra.Command { substring of its text. If it matches more than one idea it is refused and the ambiguous matches are listed, so you can be more specific or use the exact ID. --force is required to confirm the deletion; without it the command refuses to -remove anything. --main targets the main worktree's backlog, and ---file / IDEAS_FILE point elsewhere (see "idea --help"). +remove anything. --main targets the main worktree's backlog, --system targets +the system-level backlog (~/.config/idea/backlog.md), and --file / IDEAS_FILE +point elsewhere (see "idea --help"). Outside a git repo the system backlog is +used automatically. idea rm a7k2 --force`, Args: cobra.ExactArgs(1), diff --git a/src/cmd/idea/show.go b/src/cmd/idea/show.go index 8a0f019..9195e62 100644 --- a/src/cmd/idea/show.go +++ b/src/cmd/idea/show.go @@ -21,8 +21,10 @@ func showCmd() *cobra.Command { substring of its text. If it matches more than one idea it is refused and the ambiguous matches are listed, so you can be more specific or use the exact ID. --json emits the structured record (id, date, status, text) instead of the -formatted line. --main targets the main worktree's backlog, and ---file / IDEAS_FILE point elsewhere (see "idea --help"). +formatted line. --main targets the main worktree's backlog, --system targets +the system-level backlog (~/.config/idea/backlog.md), and --file / IDEAS_FILE +point elsewhere (see "idea --help"). Outside a git repo the system backlog is +used automatically. idea show a7k2 idea show "dark mode" --json`, diff --git a/src/internal/idea/idea.go b/src/internal/idea/idea.go index 11439f6..cca044e 100644 --- a/src/internal/idea/idea.go +++ b/src/internal/idea/idea.go @@ -339,6 +339,13 @@ func render(f *File, today string) (string, int) { // is cleaned up on any error path before returning. func atomicWriteFile(path string, data []byte, perm os.FileMode) error { dir := filepath.Dir(path) + // Create the parent directory on demand so the first mutating write to a + // fresh system backlog (e.g. ~/.config/idea/) succeeds instead of failing + // with "no such directory". Add already MkdirAll's its own path; this + // covers every SaveFile-based mutation (done/reopen/edit/rm/prune/fmt). + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("create directories: %w", err) + } tmp, err := os.CreateTemp(dir, ".idea-tmp-*") if err != nil { return fmt.Errorf("create temp file: %w", err) @@ -370,17 +377,101 @@ func atomicWriteFile(path string, data []byte, perm os.FileMode) error { // ResolveFilePath determines the backlog file path. // Priority: flagValue > IDEAS_FILE env > default (fab/backlog.md). -// The result is relative to repoRoot. +// The result is relative to repoRoot. An absolute flagValue / IDEAS_FILE value +// is honored verbatim (filepath.Join leaves an absolute second element intact +// only on its own; we guard it explicitly so an out-of-git config-dir root does +// not get prepended to an absolute override). func ResolveFilePath(repoRoot, flagValue string) string { if flagValue != "" { - return filepath.Join(repoRoot, flagValue) + return joinRoot(repoRoot, flagValue) } if env := os.Getenv("IDEAS_FILE"); env != "" { - return filepath.Join(repoRoot, env) + return joinRoot(repoRoot, env) } return filepath.Join(repoRoot, "fab", "backlog.md") } +// joinRoot joins an override path to root unless the override is already +// absolute, in which case it is returned verbatim. This keeps an absolute +// --file / IDEAS_FILE value stable regardless of which root resolved. +func joinRoot(root, override string) string { + if filepath.IsAbs(override) { + return override + } + return filepath.Join(root, override) +} + +// SystemBacklogPath returns the system-level backlog file path: +// +// $XDG_CONFIG_HOME/idea/backlog.md (when XDG_CONFIG_HOME is set) +// ~/.config/idea/backlog.md (otherwise) +// +// It uses os.UserConfigDir, which already honors XDG_CONFIG_HOME on Unix and +// falls back to ~/.config — keeping the path resolution stdlib-only. +func SystemBacklogPath() (string, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("resolve system config dir: %w", err) + } + return filepath.Join(configDir, "idea", "backlog.md"), nil +} + +// ResolveBacklogPath determines the backlog file path from the three persistent +// flag inputs, encoding the full resolution precedence (first match wins): +// +// 1. systemFlag set → the system backlog (git is skipped entirely). +// 2. fileFlag / IDEAS_FILE set → joined to the git root when inside a repo, +// else to the system config dir (an absolute value is honored verbatim). +// 3. mainFlag set → the main worktree root (git-only; errors +// outside a repo, unchanged). +// 4. inside a git repo → {worktree-root}/fab/backlog.md (the unchanged +// default). +// 5. outside a git repo → the system backlog (the graceful fallback). +// +// systemFlag and mainFlag are mutually exclusive: both select a root, so passing +// both is a user error and returns a non-nil error without resolving a path. +func ResolveBacklogPath(systemFlag, mainFlag bool, fileFlag string) (string, error) { + if systemFlag && mainFlag { + return "", fmt.Errorf("--system and --main are mutually exclusive; pass only one") + } + + // 1. --system forces the system backlog from anywhere, skipping git. + if systemFlag { + return SystemBacklogPath() + } + + // 4/5. The default root is the current worktree when inside a repo; outside + // a repo it is the system config dir. --main (when set) overrides this with + // the main worktree root and is always git-only. + if mainFlag { + root, err := MainRepoRoot() + if err != nil { + return "", err + } + return ResolveFilePath(root, fileFlag), nil + } + + if root, err := WorktreeRoot(); err == nil { + // Inside a git repo: unchanged default + override rooting. + return ResolveFilePath(root, fileFlag), nil + } + + // Outside any git repo: root overrides at the system config dir, and the + // no-override default is the system backlog itself. + configDir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("resolve system config dir: %w", err) + } + ideaConfigDir := filepath.Join(configDir, "idea") + if fileFlag != "" { + return joinRoot(ideaConfigDir, fileFlag), nil + } + if env := os.Getenv("IDEAS_FILE"); env != "" { + return joinRoot(ideaConfigDir, env), nil + } + return filepath.Join(ideaConfigDir, "backlog.md"), nil +} + // FilterKind specifies which ideas to include. type FilterKind int diff --git a/src/internal/idea/idea_test.go b/src/internal/idea/idea_test.go index 862a53d..ee64bea 100644 --- a/src/internal/idea/idea_test.go +++ b/src/internal/idea/idea_test.go @@ -3,6 +3,7 @@ package idea import ( "encoding/json" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -1704,3 +1705,194 @@ func TestIdeaJSON_MultilineText(t *testing.T) { t.Errorf("decoded text = %q, want %q", decoded.Text, "first\nsecond") } } + +// --- System Backlog & Resolution Precedence Tests --- + +// chdir switches the process working directory to dir for the duration of the +// test, restoring the original on cleanup. Used instead of testing.T.Chdir so +// the suite compiles against the module's declared go1.22 (T.Chdir is go1.24+). +// These tests do not call t.Parallel(), so serial CWD mutation is safe. +func chdir(t *testing.T, dir string) { + t.Helper() + orig, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir %s: %v", dir, err) + } + t.Cleanup(func() { _ = os.Chdir(orig) }) +} + +// initGitRepo initializes a real git repo in dir (Constitution V — git behavior +// is exercised against a real repo, never mocked). +func initGitRepo(t *testing.T, dir string) { + t.Helper() + for _, args := range [][]string{ + {"init"}, + {"config", "user.email", "test@test.com"}, + {"config", "user.name", "Test"}, + } { + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } +} + +func TestSystemBacklogPath(t *testing.T) { + tests := []struct { + name string + xdg string + home string + wantSfx string // expected suffix; full path when xdg is set + }{ + {"xdg set", "/custom/cfg", "/home/u", filepath.Join("/custom/cfg", "idea", "backlog.md")}, + {"xdg unset falls back to home/.config", "", "/home/u", filepath.Join("/home/u", ".config", "idea", "backlog.md")}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", tt.xdg) + t.Setenv("HOME", tt.home) + got, err := SystemBacklogPath() + if err != nil { + t.Fatalf("SystemBacklogPath: %v", err) + } + if got != tt.wantSfx { + t.Errorf("SystemBacklogPath = %q, want %q", got, tt.wantSfx) + } + }) + } +} + +func TestResolveBacklogPath_SystemMainConflict(t *testing.T) { + if _, err := ResolveBacklogPath(true, true, ""); err == nil { + t.Fatal("expected conflict error for --system + --main, got nil") + } +} + +func TestResolveBacklogPath_SystemFlagSkipsGit(t *testing.T) { + // Even inside a git repo, --system resolves to the system backlog. + repo := t.TempDir() + initGitRepo(t, repo) + cfg := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", cfg) + chdir(t, repo) + + got, err := ResolveBacklogPath(true, false, "") + if err != nil { + t.Fatalf("ResolveBacklogPath: %v", err) + } + want := filepath.Join(cfg, "idea", "backlog.md") + if got != want { + t.Errorf("--system in repo = %q, want %q", got, want) + } +} + +func TestResolveBacklogPath_InGitDefault(t *testing.T) { + repo := t.TempDir() + initGitRepo(t, repo) + t.Setenv("IDEAS_FILE", "") + chdir(t, repo) + + got, err := ResolveBacklogPath(false, false, "") + if err != nil { + t.Fatalf("ResolveBacklogPath: %v", err) + } + // macOS /tmp symlinks through /private; compare resolved roots. + wantRoot, _ := WorktreeRoot() + want := filepath.Join(wantRoot, "fab", "backlog.md") + if got != want { + t.Errorf("in-git default = %q, want %q", got, want) + } +} + +func TestResolveBacklogPath_InGitFileFlag(t *testing.T) { + repo := t.TempDir() + initGitRepo(t, repo) + t.Setenv("IDEAS_FILE", "") + chdir(t, repo) + + got, err := ResolveBacklogPath(false, false, "notes/custom.md") + if err != nil { + t.Fatalf("ResolveBacklogPath: %v", err) + } + wantRoot, _ := WorktreeRoot() + want := filepath.Join(wantRoot, "notes", "custom.md") + if got != want { + t.Errorf("in-git --file = %q, want %q", got, want) + } +} + +func TestResolveBacklogPath_OutOfGitFallback(t *testing.T) { + cfg := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", cfg) + t.Setenv("IDEAS_FILE", "") + chdir(t, t.TempDir()) // a non-git directory + + got, err := ResolveBacklogPath(false, false, "") + if err != nil { + t.Fatalf("ResolveBacklogPath: %v", err) + } + want := filepath.Join(cfg, "idea", "backlog.md") + if got != want { + t.Errorf("out-of-git fallback = %q, want %q", got, want) + } +} + +func TestResolveBacklogPath_OutOfGitFileRootsAtConfigDir(t *testing.T) { + cfg := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", cfg) + t.Setenv("IDEAS_FILE", "") + chdir(t, t.TempDir()) + + got, err := ResolveBacklogPath(false, false, "notes.md") + if err != nil { + t.Fatalf("ResolveBacklogPath: %v", err) + } + want := filepath.Join(cfg, "idea", "notes.md") + if got != want { + t.Errorf("out-of-git --file = %q, want %q", got, want) + } +} + +func TestResolveBacklogPath_OutOfGitIdeasEnvRootsAtConfigDir(t *testing.T) { + cfg := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", cfg) + t.Setenv("IDEAS_FILE", "env-notes.md") + chdir(t, t.TempDir()) + + got, err := ResolveBacklogPath(false, false, "") + if err != nil { + t.Fatalf("ResolveBacklogPath: %v", err) + } + want := filepath.Join(cfg, "idea", "env-notes.md") + if got != want { + t.Errorf("out-of-git IDEAS_FILE = %q, want %q", got, want) + } +} + +func TestResolveBacklogPath_AbsoluteFileBypassesRoot(t *testing.T) { + abs := filepath.Join(t.TempDir(), "abs-backlog.md") + t.Setenv("IDEAS_FILE", "") + + // Out of git: absolute --file is used verbatim, not rooted at config dir. + chdir(t, t.TempDir()) + got, err := ResolveBacklogPath(false, false, abs) + if err != nil { + t.Fatalf("ResolveBacklogPath: %v", err) + } + if got != abs { + t.Errorf("out-of-git absolute --file = %q, want %q", got, abs) + } +} + +func TestResolveBacklogPath_MainErrorsOutOfGit(t *testing.T) { + t.Setenv("IDEAS_FILE", "") + chdir(t, t.TempDir()) // non-git + + if _, err := ResolveBacklogPath(false, true, ""); err == nil { + t.Fatal("expected --main to error outside a git repo, got nil") + } +} From a6ba4c153509c4c021220353dcd4b1a7e99fb798 Mon Sep 17 00:00:00 2001 From: Sahil Ahuja Date: Sat, 13 Jun 2026 13:33:01 +0530 Subject: [PATCH 2/4] Update ship status and record PR URL --- .../260613-2b3m-system-level-backlog/.history.jsonl | 1 + .../260613-2b3m-system-level-backlog/.status.yaml | 12 +++++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/fab/changes/260613-2b3m-system-level-backlog/.history.jsonl b/fab/changes/260613-2b3m-system-level-backlog/.history.jsonl index eb334bf..fb58158 100644 --- a/fab/changes/260613-2b3m-system-level-backlog/.history.jsonl +++ b/fab/changes/260613-2b3m-system-level-backlog/.history.jsonl @@ -10,3 +10,4 @@ {"action":"enter","driver":"fab-fff","event":"stage-transition","stage":"hydrate","ts":"2026-06-13T07:59:06Z"} {"event":"review","result":"passed","ts":"2026-06-13T07:59:06Z"} {"action":"enter","driver":"fab-fff","event":"stage-transition","stage":"ship","ts":"2026-06-13T08:01:31Z"} +{"action":"enter","driver":"git-pr","event":"stage-transition","stage":"review-pr","ts":"2026-06-13T08:02:57Z"} diff --git a/fab/changes/260613-2b3m-system-level-backlog/.status.yaml b/fab/changes/260613-2b3m-system-level-backlog/.status.yaml index fee1001..6e88b32 100644 --- a/fab/changes/260613-2b3m-system-level-backlog/.status.yaml +++ b/fab/changes/260613-2b3m-system-level-backlog/.status.yaml @@ -9,8 +9,8 @@ progress: apply: done review: done hydrate: done - ship: active - review-pr: pending + ship: done + review-pr: active plan: generated: true task_count: 9 @@ -33,8 +33,10 @@ stage_metrics: apply: {started_at: "2026-06-13T07:46:44Z", driver: fab-fff, iterations: 1, completed_at: "2026-06-13T07:54:47Z"} review: {started_at: "2026-06-13T07:54:47Z", driver: fab-fff, iterations: 1, completed_at: "2026-06-13T07:59:06Z"} hydrate: {started_at: "2026-06-13T07:59:06Z", driver: fab-fff, iterations: 1, completed_at: "2026-06-13T08:01:31Z"} - ship: {started_at: "2026-06-13T08:01:31Z", driver: fab-fff, iterations: 1} -prs: [] + ship: {started_at: "2026-06-13T08:01:31Z", driver: fab-fff, iterations: 1, completed_at: "2026-06-13T08:02:57Z"} + review-pr: {started_at: "2026-06-13T08:02:57Z", driver: git-pr, iterations: 1} +prs: + - https://github.com/sahil87/idea/pull/19 true_impact: added: 0 deleted: 0 @@ -46,4 +48,4 @@ true_impact: computed_at: "2026-06-13T08:01:31Z" computed_at_stage: hydrate # true_impact: lazily created on first apply-finish (no placeholder here). -last_updated: 2026-06-13T08:01:31Z +last_updated: 2026-06-13T08:02:57Z From ae98f092dfaa52798f2471a4ccf7663265011ff0 Mon Sep 17 00:00:00 2001 From: Sahil Ahuja Date: Sat, 13 Jun 2026 13:42:02 +0530 Subject: [PATCH 3/4] fix: address review feedback from @Copilot --- docs/memory/cli/index.md | 2 +- docs/memory/cli/structure.md | 2 +- docs/specs/overview.md | 11 ++++++----- src/cmd/idea/add.go | 3 ++- src/cmd/idea/done.go | 6 +++--- src/cmd/idea/edit.go | 5 +++-- src/cmd/idea/fmt.go | 5 +++-- src/cmd/idea/main.go | 4 ++-- src/cmd/idea/reopen.go | 6 +++--- src/cmd/idea/rm.go | 6 +++--- src/cmd/idea/show.go | 6 +++--- src/internal/idea/idea.go | 32 +++++++++++++++++++------------- 12 files changed, 49 insertions(+), 39 deletions(-) diff --git a/docs/memory/cli/index.md b/docs/memory/cli/index.md index 09a9488..5d5066d 100644 --- a/docs/memory/cli/index.md +++ b/docs/memory/cli/index.md @@ -10,5 +10,5 @@ description: "CLI source structure (cmd/idea + internal/idea + version wiring), | [edit](edit.md) | `idea edit` two-form contract: inline replacement (two-arg form) vs. $EDITOR round-trip (`edit `) — editor resolution chain, temp-file mechanics, edge/exit semantics, and the fake-editor test seam | 2026-06-12 | | [list](list.md) | `idea list`/`ls` rendering contract: TTY-aware rune-safe text truncation, the `--full` flag, the optional `[id...]` positional filter, ANSI color (NO_COLOR-gated), and the pipe contract that keeps piped output canonical | 2026-06-13 | | [prune](prune.md) | Bulk-remove subcommand (`idea prune`): the TTY × `--force` decision matrix (pipe dry-run vs. interactive `[y/N]` confirm), the leading stderr count header, TTY-aware truncation/color/`--full` listing, stdout/stderr channel split, exit codes, the deliberate non-archival design, and the `removeIdeaAt` seam shared with `Rm` | 2026-06-13 | -| [structure](structure.md) | Source tree layout (cmd/idea + internal/idea), root command factory, backlog path resolution precedence (--system / --main / --file, the XDG ~/.config/idea/backlog.md system backlog, and the out-of-git graceful fallback), command aliases vs. the bare-text shorthand, backlog line lifecycle (lenient read / canonical write incl. the escaped-text convention for multiline ideas and the explicit `idea fmt` canonicalizer with bare-checkbox adoption), the TTY/width/color/truncation seam (internal/idea/term.go) + shared printIdeaLines render path, the golang.org/x/term direct dependency, help-dump contract, and version stamping | 2026-06-13 | +| [structure](structure.md) | Source tree layout (cmd/idea + internal/idea), root command factory, backlog path resolution precedence (--system / --main / --file, the XDG system backlog ($XDG_CONFIG_HOME/idea/backlog.md, else ~/.config/idea/backlog.md), and the out-of-git graceful fallback), command aliases vs. the bare-text shorthand, backlog line lifecycle (lenient read / canonical write incl. the escaped-text convention for multiline ideas and the explicit `idea fmt` canonicalizer with bare-checkbox adoption), the TTY/width/color/truncation seam (internal/idea/term.go) + shared printIdeaLines render path, the golang.org/x/term direct dependency, help-dump contract, and version stamping | 2026-06-13 | | [update](update.md) | Self-update subcommand (`idea update`): Homebrew-backed upgrade flow, non-brew install fallback hint, and the `--skip-brew-update` flag | 2026-06-10 | diff --git a/docs/memory/cli/structure.md b/docs/memory/cli/structure.md index 3720ae2..65e781b 100644 --- a/docs/memory/cli/structure.md +++ b/docs/memory/cli/structure.md @@ -1,5 +1,5 @@ --- -description: "Source tree layout (cmd/idea + internal/idea), root command factory, backlog path resolution precedence (--system / --main / --file, the XDG ~/.config/idea/backlog.md system backlog, and the out-of-git graceful fallback), command aliases vs. the bare-text shorthand, backlog line lifecycle (lenient read / canonical write incl. the escaped-text convention for multiline ideas and the explicit `idea fmt` canonicalizer with bare-checkbox adoption), the TTY/width/color/truncation seam (internal/idea/term.go) + shared printIdeaLines render path, the golang.org/x/term direct dependency, help-dump contract, and version stamping" +description: "Source tree layout (cmd/idea + internal/idea), root command factory, backlog path resolution precedence (--system / --main / --file, the XDG system backlog ($XDG_CONFIG_HOME/idea/backlog.md, else ~/.config/idea/backlog.md), and the out-of-git graceful fallback), command aliases vs. the bare-text shorthand, backlog line lifecycle (lenient read / canonical write incl. the escaped-text convention for multiline ideas and the explicit `idea fmt` canonicalizer with bare-checkbox adoption), the TTY/width/color/truncation seam (internal/idea/term.go) + shared printIdeaLines render path, the golang.org/x/term direct dependency, help-dump contract, and version stamping" --- # CLI Source Structure diff --git a/docs/specs/overview.md b/docs/specs/overview.md index a7e277b..800736a 100644 --- a/docs/specs/overview.md +++ b/docs/specs/overview.md @@ -30,13 +30,14 @@ The backlog file path can also be overridden globally by `--file ` or by s `idea` also works **outside any git repository** and offers a **system-level backlog** for cross-repo idea capture. The system backlog lives at `$XDG_CONFIG_HOME/idea/backlog.md` when `XDG_CONFIG_HOME` is set, and `~/.config/idea/backlog.md` otherwise (resolved via Go's `os.UserConfigDir`). Its parent directory is created on demand on the first mutating write. The file format and all command semantics are identical to a repo backlog — only the path differs. -The backlog path is resolved by this precedence (first match wins): +The backlog path is resolved by this precedence (first match wins). Note that `--main` and `--file`/`IDEAS_FILE` are **not** independent alternatives: `--main` selects which *root* is used, and `--file`/`IDEAS_FILE` (when set) are applied *within* that selected root. 1. **`--system`** — the system backlog, skipping git entirely (reachable from inside a repo too). -2. **`--file ` / `IDEAS_FILE`** — joined to the git root when inside a repo, else to the system config dir (`~/.config/idea/`). An absolute value is used verbatim. -3. **`--main`** — the main worktree root. Git-only: it still errors with `not in a git repository` outside a repo. -4. **In a git repo, no override** — `{worktree-root}/fab/backlog.md` (the default). -5. **Outside a git repo, no override** — the system backlog (the graceful fallback; commands no longer fail with `not in a git repository`). +2. **`--main`** — root = the main worktree root. Git-only: it still errors with `not in a git repository` outside a repo. A `--file`/`IDEAS_FILE` value, if set, is rooted here. +3. **In a git repo (no `--main`)** — root = the current worktree root. A `--file`/`IDEAS_FILE` value, if set, is rooted here; otherwise `{worktree-root}/fab/backlog.md` (the default). +4. **Outside a git repo (no `--main`)** — root = the system config dir (`$XDG_CONFIG_HOME/idea/`, else `~/.config/idea/`). A `--file`/`IDEAS_FILE` value, if set, is rooted here; otherwise the system backlog (the graceful fallback; commands no longer fail with `not in a git repository`). + +In all rooted cases an absolute `--file`/`IDEAS_FILE` value is used verbatim. `--system` and `--main` are mutually exclusive — passing both is a user error and exits non-zero. diff --git a/src/cmd/idea/add.go b/src/cmd/idea/add.go index 13130f3..7e31e12 100644 --- a/src/cmd/idea/add.go +++ b/src/cmd/idea/add.go @@ -19,7 +19,8 @@ The idea is appended as a Markdown checklist line with a generated 4-char ID and today's date. Use --id and --date to override those generated values (handy when importing or backdating). By default the command writes the current worktree's backlog; --main targets the main worktree's backlog, ---system targets the system-level backlog (~/.config/idea/backlog.md), and +--system targets the system-level backlog ($XDG_CONFIG_HOME/idea/backlog.md, +else ~/.config/idea/backlog.md), and --file / IDEAS_FILE point at a different file (see "idea --help"). Outside a git repo the system backlog is used automatically. diff --git a/src/cmd/idea/done.go b/src/cmd/idea/done.go index eb9efbe..0b596d0 100644 --- a/src/cmd/idea/done.go +++ b/src/cmd/idea/done.go @@ -17,9 +17,9 @@ func doneCmd() *cobra.Command { its text. If the query matches more than one open idea it is refused and the ambiguous matches are listed, so you can be more specific or use the exact ID. --main targets the main worktree's backlog, --system targets the system-level -backlog (~/.config/idea/backlog.md), and --file / IDEAS_FILE point elsewhere -(see "idea --help"). Outside a git repo the system backlog is used -automatically. +backlog ($XDG_CONFIG_HOME/idea/backlog.md, else ~/.config/idea/backlog.md), and +--file / IDEAS_FILE point elsewhere (see "idea --help"). Outside a git repo the +system backlog is used automatically. idea done a7k2 idea done "dark mode"`, diff --git a/src/cmd/idea/edit.go b/src/cmd/idea/edit.go index 6f3ee32..262a23e 100644 --- a/src/cmd/idea/edit.go +++ b/src/cmd/idea/edit.go @@ -27,8 +27,9 @@ substring of its text. If it matches more than one idea it is refused and the ambiguous matches are listed, so you can be more specific or use the exact ID. --id and --date additionally change the matched idea's ID or date. --main targets the main worktree's backlog, --system targets the system-level backlog -(~/.config/idea/backlog.md), and --file / IDEAS_FILE point elsewhere (see -"idea --help"). Outside a git repo the system backlog is used automatically. +($XDG_CONFIG_HOME/idea/backlog.md, else ~/.config/idea/backlog.md), and +--file / IDEAS_FILE point elsewhere (see "idea --help"). Outside a git repo the +system backlog is used automatically. idea edit a7k2 "wire up dark mode toggle" idea edit a7k2 # open the current text in your editor diff --git a/src/cmd/idea/fmt.go b/src/cmd/idea/fmt.go index acd7f38..ea9cd9e 100644 --- a/src/cmd/idea/fmt.go +++ b/src/cmd/idea/fmt.go @@ -29,8 +29,9 @@ stdout stays empty; the report (one "adopted:" line per adopted idea plus summary counts) goes to stderr. --check writes nothing, prints the same report, and exits 1 when the file would change, 0 when it is already canonical. --main targets the main worktree's backlog, --system targets the system-level backlog -(~/.config/idea/backlog.md), and --file / IDEAS_FILE point elsewhere (see -"idea --help"). Outside a git repo the system backlog is used automatically. +($XDG_CONFIG_HOME/idea/backlog.md, else ~/.config/idea/backlog.md), and +--file / IDEAS_FILE point elsewhere (see "idea --help"). Outside a git repo the +system backlog is used automatically. idea fmt idea fmt --check`, diff --git a/src/cmd/idea/main.go b/src/cmd/idea/main.go index 4966e90..848436f 100644 --- a/src/cmd/idea/main.go +++ b/src/cmd/idea/main.go @@ -49,9 +49,9 @@ Shorthand: "idea " is equivalent to "idea add ".`, }, } - root.PersistentFlags().StringVar(&fileFlag, "file", "", "Override backlog file path (relative to the git root, or to ~/.config/idea when outside a repo)") + root.PersistentFlags().StringVar(&fileFlag, "file", "", "Override backlog file path (relative to the git root, or to the system config dir $XDG_CONFIG_HOME/idea — else ~/.config/idea — when outside a repo)") root.PersistentFlags().BoolVar(&mainFlag, "main", false, "Operate on the main worktree's backlog instead of the current worktree") - root.PersistentFlags().BoolVar(&systemFlag, "system", false, "Operate on the system-level backlog (~/.config/idea/backlog.md) instead of a repo backlog") + root.PersistentFlags().BoolVar(&systemFlag, "system", false, "Operate on the system-level backlog ($XDG_CONFIG_HOME/idea/backlog.md, else ~/.config/idea/backlog.md) instead of a repo backlog") root.AddCommand( addCmd(), diff --git a/src/cmd/idea/reopen.go b/src/cmd/idea/reopen.go index 1ed5c9f..1f22724 100644 --- a/src/cmd/idea/reopen.go +++ b/src/cmd/idea/reopen.go @@ -17,9 +17,9 @@ func reopenCmd() *cobra.Command { its text. If the query matches more than one done idea it is refused and the ambiguous matches are listed, so you can be more specific or use the exact ID. --main targets the main worktree's backlog, --system targets the system-level -backlog (~/.config/idea/backlog.md), and --file / IDEAS_FILE point elsewhere -(see "idea --help"). Outside a git repo the system backlog is used -automatically. +backlog ($XDG_CONFIG_HOME/idea/backlog.md, else ~/.config/idea/backlog.md), and +--file / IDEAS_FILE point elsewhere (see "idea --help"). Outside a git repo the +system backlog is used automatically. idea reopen a7k2 idea reopen "dark mode"`, diff --git a/src/cmd/idea/rm.go b/src/cmd/idea/rm.go index ac73324..61cad66 100644 --- a/src/cmd/idea/rm.go +++ b/src/cmd/idea/rm.go @@ -20,9 +20,9 @@ substring of its text. If it matches more than one idea it is refused and the ambiguous matches are listed, so you can be more specific or use the exact ID. --force is required to confirm the deletion; without it the command refuses to remove anything. --main targets the main worktree's backlog, --system targets -the system-level backlog (~/.config/idea/backlog.md), and --file / IDEAS_FILE -point elsewhere (see "idea --help"). Outside a git repo the system backlog is -used automatically. +the system-level backlog ($XDG_CONFIG_HOME/idea/backlog.md, else +~/.config/idea/backlog.md), and --file / IDEAS_FILE point elsewhere (see +"idea --help"). Outside a git repo the system backlog is used automatically. idea rm a7k2 --force`, Args: cobra.ExactArgs(1), diff --git a/src/cmd/idea/show.go b/src/cmd/idea/show.go index 9195e62..1f782f0 100644 --- a/src/cmd/idea/show.go +++ b/src/cmd/idea/show.go @@ -22,9 +22,9 @@ substring of its text. If it matches more than one idea it is refused and the ambiguous matches are listed, so you can be more specific or use the exact ID. --json emits the structured record (id, date, status, text) instead of the formatted line. --main targets the main worktree's backlog, --system targets -the system-level backlog (~/.config/idea/backlog.md), and --file / IDEAS_FILE -point elsewhere (see "idea --help"). Outside a git repo the system backlog is -used automatically. +the system-level backlog ($XDG_CONFIG_HOME/idea/backlog.md, else +~/.config/idea/backlog.md), and --file / IDEAS_FILE point elsewhere (see +"idea --help"). Outside a git repo the system backlog is used automatically. idea show a7k2 idea show "dark mode" --json`, diff --git a/src/internal/idea/idea.go b/src/internal/idea/idea.go index cca044e..d1e351e 100644 --- a/src/internal/idea/idea.go +++ b/src/internal/idea/idea.go @@ -377,10 +377,9 @@ func atomicWriteFile(path string, data []byte, perm os.FileMode) error { // ResolveFilePath determines the backlog file path. // Priority: flagValue > IDEAS_FILE env > default (fab/backlog.md). -// The result is relative to repoRoot. An absolute flagValue / IDEAS_FILE value -// is honored verbatim (filepath.Join leaves an absolute second element intact -// only on its own; we guard it explicitly so an out-of-git config-dir root does -// not get prepended to an absolute override). +// A relative override is resolved against repoRoot; an absolute flagValue / +// IDEAS_FILE value is honored verbatim (via joinRoot, which short-circuits on +// an absolute override) so it stays stable regardless of which root resolved. func ResolveFilePath(repoRoot, flagValue string) string { if flagValue != "" { return joinRoot(repoRoot, flagValue) @@ -417,16 +416,23 @@ func SystemBacklogPath() (string, error) { } // ResolveBacklogPath determines the backlog file path from the three persistent -// flag inputs, encoding the full resolution precedence (first match wins): +// flag inputs. systemFlag short-circuits everything; otherwise mainFlag selects +// which root is used, and fileFlag / IDEAS_FILE (if any) are applied *within* +// that selected root — so --file and --main are not independent alternatives, +// they compose. The precedence (first match wins): // -// 1. systemFlag set → the system backlog (git is skipped entirely). -// 2. fileFlag / IDEAS_FILE set → joined to the git root when inside a repo, -// else to the system config dir (an absolute value is honored verbatim). -// 3. mainFlag set → the main worktree root (git-only; errors -// outside a repo, unchanged). -// 4. inside a git repo → {worktree-root}/fab/backlog.md (the unchanged -// default). -// 5. outside a git repo → the system backlog (the graceful fallback). +// 1. systemFlag set → the system backlog (git is skipped entirely). +// 2. mainFlag set → root = main worktree root (git-only; errors outside a +// repo, unchanged). fileFlag / IDEAS_FILE, if set, are rooted here. +// 3. inside a git repo → root = current worktree root. fileFlag / IDEAS_FILE, +// if set, are rooted here; otherwise {worktree-root}/fab/backlog.md (the +// unchanged default). +// 4. outside a git repo → root = system config dir. fileFlag / IDEAS_FILE, if +// set, are rooted here; otherwise the system backlog (the graceful +// fallback). +// +// In all rooted cases an absolute fileFlag / IDEAS_FILE value is honored +// verbatim (see joinRoot). // // systemFlag and mainFlag are mutually exclusive: both select a root, so passing // both is a user error and returns a non-nil error without resolving a path. From 7bd32ff57f281a32d457af304c4266d5501388b1 Mon Sep 17 00:00:00 2001 From: Sahil Ahuja Date: Sat, 13 Jun 2026 13:43:26 +0530 Subject: [PATCH 4/4] Update review-pr status --- fab/changes/260613-2b3m-system-level-backlog/.history.jsonl | 1 + fab/changes/260613-2b3m-system-level-backlog/.status.yaml | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/fab/changes/260613-2b3m-system-level-backlog/.history.jsonl b/fab/changes/260613-2b3m-system-level-backlog/.history.jsonl index fb58158..59b1fca 100644 --- a/fab/changes/260613-2b3m-system-level-backlog/.history.jsonl +++ b/fab/changes/260613-2b3m-system-level-backlog/.history.jsonl @@ -11,3 +11,4 @@ {"event":"review","result":"passed","ts":"2026-06-13T07:59:06Z"} {"action":"enter","driver":"fab-fff","event":"stage-transition","stage":"ship","ts":"2026-06-13T08:01:31Z"} {"action":"enter","driver":"git-pr","event":"stage-transition","stage":"review-pr","ts":"2026-06-13T08:02:57Z"} +{"event":"review","result":"passed","ts":"2026-06-13T08:13:06Z"} diff --git a/fab/changes/260613-2b3m-system-level-backlog/.status.yaml b/fab/changes/260613-2b3m-system-level-backlog/.status.yaml index 6e88b32..a91676e 100644 --- a/fab/changes/260613-2b3m-system-level-backlog/.status.yaml +++ b/fab/changes/260613-2b3m-system-level-backlog/.status.yaml @@ -10,7 +10,7 @@ progress: review: done hydrate: done ship: done - review-pr: active + review-pr: done plan: generated: true task_count: 9 @@ -34,7 +34,7 @@ stage_metrics: review: {started_at: "2026-06-13T07:54:47Z", driver: fab-fff, iterations: 1, completed_at: "2026-06-13T07:59:06Z"} hydrate: {started_at: "2026-06-13T07:59:06Z", driver: fab-fff, iterations: 1, completed_at: "2026-06-13T08:01:31Z"} ship: {started_at: "2026-06-13T08:01:31Z", driver: fab-fff, iterations: 1, completed_at: "2026-06-13T08:02:57Z"} - review-pr: {started_at: "2026-06-13T08:02:57Z", driver: git-pr, iterations: 1} + review-pr: {started_at: "2026-06-13T08:02:57Z", driver: git-pr, iterations: 1, completed_at: "2026-06-13T08:13:06Z"} prs: - https://github.com/sahil87/idea/pull/19 true_impact: @@ -48,4 +48,4 @@ true_impact: computed_at: "2026-06-13T08:01:31Z" computed_at_stage: hydrate # true_impact: lazily created on first apply-finish (no placeholder here). -last_updated: 2026-06-13T08:02:57Z +last_updated: 2026-06-13T08:13:06Z