diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 0aa7fae..710c2b3 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -112,6 +112,20 @@ Project-wide constraints that gate everything: ## D17. Plugin system — **deferred to v2** **Decision:** v1 ships built-in providers compiled in-process behind Go interfaces; **no `go-plugin`/gRPC**. Reserve `/pkg/pluginsdk`. Add `hashicorp/go-plugin` (MPL-2.0; local-subprocess; TCP-loopback transport on Windows) only when third parties want out-of-tree secrets/tunnel providers. Go's native `plugin` package is rejected (Linux/macOS-only, version-brittle, panics crash the host). +## D18. Active context & shell integration +**Decision:** Introduce an **active-context** model (a "current workspace/project", kubectl-style) resolved in precedence `--project` → `DEVSTACK_PROJECT`/`DEVSTACK_WORKSPACE` env → a **persisted default** → the existing single/first-project fallback. The persisted default is a single-row `active(ctx, workspace_root, project)` table in the SQLite ledger, **keyed by Docker context** (DECISIONS D6) and written under the flock via an **append-only, forward-only** migration (spec 08). Switching (`devstack use`) is delivered as an **opt-in shell `eval` hook** — `eval "$(devstack shell-init zsh|fish|bash)"` installs a `devstack()` wrapper that `eval`s `use`'s emitted `cd`/`export`, plus completions and a prompt segment — because **a child process cannot mutate its parent shell**. Authoring TUIs (`project`, `env`) that edit *hand-written* user YAML use a goccy **AST round-trip** editor (preserve comments/order), never the generate-side `writeIfChanged`. The ASCII logo is injected via cobra `SetHelpFunc`/`SetUsageTemplate`, gated under `--json`/`--quiet`. Detail: [spec 30](specs/30-interactive-dx-and-shell.md). + +**Why:** The tool was context-blind and print-only; a persisted default gives cross-terminal continuity while the per-shell env override keeps multiple terminals independent. The `eval` hook is the *only* correct way to make `use` change the live shell (the "execute, don't print" requirement) — and, being opt-in, it does **not** reintroduce the mandatory direnv that D14/spec 07 deliberately dropped for `argv[0]` symlinks. + +**⚠️ Verified corrections:** **fang v2.0.1 has no banner/logo option** (only `WithVersion`/`WithCommit`/`WithTheme`/`WithColorSchemeFunc`/`WithErrorHandler`/`WithNotifySignal`/`WithoutCompletions`/`WithoutManpage`/`WithoutVersion` — verified in the module cache), so the logo must go through cobra's help hook, not fang. Completions are *generatable* today via fang's `completion` subcommand but **installed nowhere** — the installer only prints a PATH hint (`install.sh`), so `shell-init` + `.goreleaser.yaml` packaging must add the missing wiring. Editing user `devstack.yaml` with the struct-marshal path would reflow the file and drop comments — the AST round-trip is mandatory. + +## D19. Task graph — a non-container "task" kind, not a service +**Decision:** Monorepo/Turborepo-style "run commands across packages" is a **new, orthogonal execution kind**: an optional `tasks:` block (`command`, `run: host|exec`, `deps: []`, `watch`) driven by `devstack run `, executed as a topological DAG over the **existing `internal/hooks` command executor** (host/exec, timeout/retries/onFailure) with the **`ws` errgroup bounded-parallel** pattern. Tasks are **not containers, not shared services, and take no flock** — they touch no ledger/infra state, so nothing about the deterministic compose pipeline, ref-counting, or the never-recreate-a-stateful guard is involved. Turborepo default is `turbo run` **passthrough** (Turbo owns its own cache/graph); mapping `turbo.json` into `tasks:` so devstack owns scheduling is opt-in. Detail: [spec 31](specs/31-js-monorepo-templates-and-run.md). + +**Why:** devstack orchestrates containers + shared infra and had no task graph; modeling tasks as containers would be wrong (short-lived, host-toolchain, dependency-ordered). Reusing the hooks executor avoids a second command runner, and keeping tasks lock-free and infra-free keeps the concurrency and determinism guarantees intact. Passthrough-by-default avoids re-implementing (and fighting) Turbo's caching. + +**⚠️ Verified corrections:** The `internal/hooks` `Hook` shape (`run` host|exec, `command` never shell-split, `workdir`/`env`/`timeout`/`retries`/`onFailure`) already is the task shape — a `Task` is that plus `deps`/`watch`; do **not** fork a parallel executor. Hot-reload templates must bind-mount source **and** mask `node_modules` with an anonymous volume, and set polling env (`CHOKIDAR_USEPOLLING`/`WATCHPACK_POLLING`) on WSL2/9p where inotify is unreliable (the same reason `internal/xdg` refuses `/mnt`). + --- ## Dependency-risk register (pin everything; wrap risky ones) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index cd08803..14ff262 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -120,3 +120,5 @@ A truth-pass over already-shipped code: fix the stale README (up/down/secrets/tr | 19 | Imperative resource commands (db/s3/queue/stream/aws) | ~5w | v0.x — db/s3 now (M9) · [spec 29](specs/29-resource-commands.md) | | 20 | Data-plane resource layer (`resources:` + Provisioners) | ~4.5w | v0.x (M9) · [spec 27](specs/27-resource-layer.md) | | 21 | CLI completeness & README reconciliation | 2w | v0.3.0 — ships first (M9) · [spec 26](specs/26-cli-completeness.md) | +| 22 | Interactive DX: active context, shell integration & authoring TUIs | ~6w | proposed (M10 DX) · [spec 30](specs/30-interactive-dx-and-shell.md) · [D18](DECISIONS.md) | +| 23 | JS/monorepo templates + task-graph runner (`devstack run`) | ~8w | proposed (M10 DX) · [spec 31](specs/31-js-monorepo-templates-and-run.md) · [D19](DECISIONS.md) | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 18ea924..b53a5ce 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -127,6 +127,9 @@ Effort is **person-weeks at production OSS quality** (tests + docs + cross-platf | **Full v1 (all pillars)** | **54** | **~13–15 months** | | Beta DX lane (M8, 0.x — post-GA) | ~8 | +~2.5 months | | Local-cloud platform lane (M9, 0.x) | ~14 | +~4 months | +| Interactive-DX lane (M10, proposed — 0.x) | ~14 | +~4 months | + +> **M10 — Interactive-DX lane (proposed).** An active-context model + shell integration (`use`/`context`/`shell-init` eval hook, completions, prompt segment), project + env/secrets authoring TUIs, a console context header, ASCII-logo branding, a JS/TS + monorepo template library with hot reload, and a devstack-native task-graph runner (`devstack run`). Specs: [30](specs/30-interactive-dx-and-shell.md) (interactive DX & shell) · [31](specs/31-js-monorepo-templates-and-run.md) (JS/monorepo templates + `run`). ADRs: [D18](DECISIONS.md) (active context & shell) · [D19](DECISIONS.md) (task graph). Strictly additive; stays 0.x; no change to the deterministic `generate` pipeline or lock-first concurrency. Calendar applies a 0.6–0.75 throughput factor (context-switching, Docker/WSL2/macOS debugging, dependency churn, docs, CI). Treat as planning ranges, not commitments. diff --git a/docs/guide/whats-next.md b/docs/guide/whats-next.md index 8959864..6169858 100644 --- a/docs/guide/whats-next.md +++ b/docs/guide/whats-next.md @@ -7,6 +7,14 @@ roadmap or still a genuine gap. It's grounded in [ROADMAP.md](../ROADMAP.md) and the specs under [`docs/specs/`](../specs/) — where something doesn't exist yet, this page says so plainly. +> **Now designed (proposed M10 "Interactive-DX" lane).** The three gaps below — +> a projects/env TUI, the monorepo task-runner, and framework templates — now have +> written RFCs: [spec 30](../specs/30-interactive-dx-and-shell.md) (active context, +> shell integration, project + env/secrets TUIs, console header, logo) and +> [spec 31](../specs/31-js-monorepo-templates-and-run.md) (JS/monorepo templates + +> hot reload + `devstack run` task graph), with ADRs +> [D18](../DECISIONS.md)/[D19](../DECISIONS.md). Still design-stage, not shipped. + ## Shipped today The core is real and in daily use: diff --git a/docs/specs/30-interactive-dx-and-shell.md b/docs/specs/30-interactive-dx-and-shell.md new file mode 100644 index 0000000..c8c64a6 --- /dev/null +++ b/docs/specs/30-interactive-dx-and-shell.md @@ -0,0 +1,83 @@ +# Spec 30 — Interactive DX: active context, shell integration & authoring TUIs + +**Module:** `internal/cli` (`use`, `context`, `shell-init`, `project`, `env`), `internal/activectx` (the persisted-context store, new), `internal/branding` (logo asset, new), `internal/prompt`/`internal/tui` (the shared TUI substrate), `internal/state` (active-context row), `internal/alias` (`Branding`) · **Milestone:** post-GA DX lane (0.x beta line; never forces v1.0.0) · **Effort:** ~6w (phased) · **ADR:** [D18](../DECISIONS.md#d18-active-context--shell-integration) + +## Purpose +devstack is fast and correct but **cold**: every command starts with no context banner, there is **no notion of an "active" workspace or project** (each data-plane command re-derives "the single/first project" via `defaultProject` — [`internal/cli/resource.go`](../../internal/cli/resource.go) line ~337 — and `defaultProjectFromModel`, [`internal/cli/messaging.go`](../../internal/cli/messaging.go) line ~113), switching context means `cd`-ing by hand, and the installer **only prints a PATH hint** ([`install.sh`](../../install.sh) lines ~128-134) — it never wires zsh/fish, completions, or an `eval` hook. There are also no TUIs for authoring **projects** or **env/secrets**, even though the interactive substrate already exists (`internal/prompt`, the `dashboard` Bubble Tea model). This spec adds the DX layer: an **active-context model**, a **`use`/`context`** pair, an opt-in **`shell-init` eval hook** (the "execute, don't print" fix — the enabler for real switching, completions, and a prompt segment), a **project TUI**, an **env/secrets TUI**, a **console context header**, and **ASCII-logo branding**. Everything is built on the substrate devstack already ships; nothing here changes the deterministic `generate` pipeline or the lock-first concurrency model. + +This spec extends the interactive lineage of [spec 22](22-init-wizard.md) (init wizard), [spec 23](23-template-authoring.md) (`template new`) and [spec 24](24-env-ingestion.md) (`secrets ingest`), reusing their `internal/prompt` theme and the non-TTY fallback contract (ARCHITECTURE §7.9). The JS/monorepo template library and the task-graph runner are a **separate** RFC, [spec 31](31-js-monorepo-templates-and-run.md). + +## Decisions + +### Active context (the "current workspace/project", kubectl-style) +- **Two-layer resolution: a persisted default + a per-shell override.** A machine-global "active context" is the default; any terminal overrides it per-shell via env. Resolution order for every command, in precedence: `--project` flag → `DEVSTACK_PROJECT` env → persisted active project → the existing `defaultProject` fallback (single/first project). Workspace resolution likewise: `DEVSTACK_WORKSPACE` env (already honored, [`internal/config/discover.go`](../../internal/config/discover.go)) → persisted active workspace → the upward `workspace.yaml` walk. **No behavior changes when nothing is set** — the current single/first-project default still wins, so existing scripts are unaffected. +- **The persisted default lives in the SQLite ledger, keyed by Docker context.** A new single-row `active(ctx, workspace_root, project, updated_at)` table in `internal/state` via an **append-only, forward-only migration** (spec 08 rule; never edit a released migration). Keyed by Docker context like the rest of the ledger (DECISIONS D6) so WSL2's two daemons don't share an active context. Written under the flock (it is a state mutation); reads are lock-free snapshots. See `[Q-CTX-SCOPE]`. +- **`defaultProject`/`defaultProjectFromModel` become the single resolution seam.** Both helpers gain the env→persisted lookup ahead of their current sorted-first fallback, so **all** db/s3/queue/topic/stream/resource/shell commands inherit active-context awareness with no per-command change. + +### `use` + `context` +- **`devstack use [name]` sets the active context; bare + TTY opens a fuzzy picker.** `name` may be a workspace (registered in the ledger — `workspace list`) or a project in the current workspace. With no arg on a TTY it launches a **Bubble Tea v2 fuzzy-list picker** (the `dashboard` model pattern, [`internal/cli/dashboard_model.go`](../../internal/cli/dashboard_model.go), `bubbles/v2/list`). Because **a child process cannot mutate its parent shell**, `use` behaves in two modes: (a) **under the shell hook** it prints `cd ` / `export DEVSTACK_WORKSPACE=… DEVSTACK_PROJECT=…` on stdout for the wrapper function to `eval`; (b) **without the hook** it writes the persisted default and prints a human confirmation + a one-line "add `eval \"$(devstack shell-init …)\"` for in-shell switching" hint. This is the direct answer to "today it only prints, it should execute". +- **`devstack context` prints the resolved context; honors `--json`.** Workspace name + root, active project, the project's Postgres **role + grant tier** ([`internal/provision`](../../internal/provision), read/write/admin from spec 29), Docker context + `backend` target (local vs remote — `config.BackendConfig.IsRemote()`), and version. All fields come from `buildManager` ([`internal/cli/shared.go`](../../internal/cli/shared.go) line ~147); no new data sources. `--json` emits the same as a machine object. This is the non-shell way to answer "which workspace/project/role am I in". + +### Shell integration — `devstack shell-init ` +- **One command emits everything a shell needs; the user `eval`s it.** `devstack shell-init zsh|fish|bash` prints, for the target shell: (a) the install-dir **PATH export** (replacing the print-only hint in `install.sh`), (b) **completion loading** via fang's already-available `completion` subcommand ([`internal/cli/root.go`](../../internal/cli/root.go) line ~124; fang provides `completion` — spec 07), (c) the **`devstack()` wrapper function** that runs the binary and, for `use`/`cd`, `eval`s its stdout so switching mutates the live shell, and (d) an **opt-in prompt-segment** function. Users add `eval "$(devstack shell-init zsh)"` to `~/.zshrc` / `~/.config/fish/config.fish`. This is a purely **opt-in** hook — it does **not** reintroduce mandatory direnv (spec 07 deliberately dropped direnv for `argv[0]` symlinks); the symlink aliasing keeps working untouched. +- **Ship real plugin artifacts + teach the installer.** Add a `completions/` tree (generated bash/zsh/fish) and a minimal oh-my-zsh plugin dir + a `*.plugin.fish`, packaged by `.goreleaser.yaml` (today it packages only docs). `install.sh` gains **shell detection** (`$SHELL`/`$ZSH_VERSION`/`$FISH_VERSION`) and, interactively, offers to append the `eval` line to the right rc file (never silently; prints the exact line under `--quiet`/non-TTY). Completions today are generatable but **installed nowhere** — this closes that gap. +- **A prompt segment surfaces context always-on.** The `shell-init` output defines a `devstack_prompt_info` helper (workspace ∕ project ∕ role, kube-ps1 style) users can splice into `PROMPT`/`fish_prompt`. It shells `devstack context --quiet --prompt` (a fast, lock-free path that reads the ledger snapshot only). See `[Q-PROMPT-COST]`. + +### Authoring TUIs (project, env/secrets) +- **`devstack project` group — `new`/`edit`/`list`/`rm`.** TTY → a Bubble Tea v2 model (the `dashboard` pattern + `internal/prompt.IsInteractive` gate, [`internal/prompt/prompt.go`](../../internal/prompt/prompt.go) line ~17); non-TTY/`--json`/`--no-input` → a flag path. Follows the **"two faces, one builder"** rule from [spec 22](22-init-wizard.md) ([`internal/cli/init_tui.go`](../../internal/cli/init_tui.go) line ~23): wizard and flags feed **one** pure builder that emits or edits `devstack.yaml` (Service `template`/`params`/`uses`/`ports`/`env`/`healthcheck`), and registers the project in `workspace.yaml`'s `projects:`. +- **Editing existing files must preserve comments/order — an AST round-trip, not struct marshal.** `project edit`/`env` mutate a hand-written user `devstack.yaml`, so they use a goccy **AST-level** editor (surgical node updates) rather than the generate-side `writeIfChanged` (which owns *generated* artifacts and would reflow the file). New emitters live beside `scaffold.EmitWorkspaceYAML` (spec 22) as `scaffold.EditProjectYAML`. See `[Q-YAML-ROUNDTRIP]`. +- **`devstack env` group — a key→value editor for local vars AND secrets.** An interactive editor over a service's `env.raw`/`env.prefixed` (plain local KV — **no external store needed**, the mechanism documented in the usage guide) and `secret://` values, reusing the huh-form pattern of [`internal/cli/secrets_ingest_tui.go`](../../internal/cli/secrets_ingest_tui.go) + `prompt.Theme()`. Local vs secret classification mirrors `secrets ingest` ([spec 24](24-env-ingestion.md)); secret values route through the existing providers and are **never written to disk** (the spec 04 valueless-env-key coupling). **"Workspace-scoped env" is a TUI convenience** meaning "write this key to every project" — it is **written per-project into each `devstack.yaml`**, with **no `workspace.yaml` schema change** (deliberate decision — keeps [spec 01](01-config-schema.md) untouched). + +### Console context header + branding +- **A shared `renderContextHeader(mgr, g)` helper, gated like the update notice.** A compact one/two-line header (workspace · project · role · docker-context · version) printed atop `status` and `up` output, suppressed `if g.JSON || g.Quiet` — the established precedent (the self-update notice returns early on `--json`/`--quiet`, [`internal/cli/root.go`](../../internal/cli/root.go) line ~61). The same projection backs `devstack context` and the prompt segment (one code path, three surfaces). +- **ASCII logo via cobra's help hook — fang has no logo option.** Embed a `devstack` logo (`internal/branding/logo.txt`, `go:embed`), rendered `lipgloss`-styled through cobra `root.SetHelpFunc`/`SetUsageTemplate` (**currently unused** — confirmed) on `--help`, on a bare invocation, and above `version`. **fang v2.0.1 exposes no banner/logo injection** (only `WithVersion`/`WithCommit`/`WithTheme`/`WithColorSchemeFunc`/… — verified in the module cache), so the cobra help func is the injection point. All decorative output is gated under `--json`/`--quiet`. Add optional `Logo`/`Tagline` fields to `alias.Branding` ([`internal/alias/alias.go`](../../internal/alias/alias.go) line ~243) so `rq`/`uranus` aliases can rebrand. + +### Cross-cutting +- **No new mutation escapes the lock.** The only state write here is the active-context row → under the flock. Everything else (`context`, header, `shell-init`, logo) is read-only or pure output. TUIs never enter the Bubble Tea runtime on a non-TTY (`prompt.IsInteractive`), preserving the headline `--json`/`--quiet` contract. +- **New deps: none beyond the v2 charm stack** already added in [spec 22](22-init-wizard.md) (`bubbletea/v2`/`bubbles/v2`/`huh/v2`/`lipgloss/v2`/`fang/v2`) — all pure-Go, CGO-free. + +## CLI surface +``` +devstack use [name] # set active workspace/project; bare+TTY → fuzzy picker + --project # force-select a project in the current workspace + --print # emit the eval script even without the shell hook + +devstack context # print resolved workspace/project/role/context/version + --json # machine object + --prompt # terse single-line form for a shell prompt segment + +devstack shell-init # emit PATH + completions + devstack() wrapper + prompt helper + # usage: eval "$(devstack shell-init zsh)" + +devstack project new [name] # author a devstack.yaml (+ register in workspace.yaml) +devstack project edit [name] # AST round-trip edit of an existing devstack.yaml +devstack project list # list workspace projects (+ --json) +devstack project rm # unregister a project (--yes; never deletes repo files) + # shared: --template --uses --port --env k=v ... --dry-run --force --no-input + +devstack env [service] # interactive key→value editor (local env.raw + secret://) + --project # target project (default: active/first) + --all-projects # apply a key to every project (written per-project) + --set KEY=VALUE # non-interactive set (repeatable) + --secret KEY # mark KEY as a secret:// value (routed to a provider) + --unset KEY # remove a key + --json --no-input # scriptable / CI + +# branding: no new command — logo renders on `--help`, bare invocation, and `version` +``` + +## Acceptance criteria +- With `eval "$(devstack shell-init zsh)"` loaded, `devstack use ` changes the current shell's directory and sets `DEVSTACK_WORKSPACE`/`DEVSTACK_PROJECT`; without the hook it persists the default and prints a hint (exit 0). `` completes commands + live project/service names. +- `devstack context --json` returns the resolved workspace, project, Postgres role + grant tier, Docker context/backend, and version; the plain form prints the same as a header. +- All db/s3/queue/topic/stream/resource/shell commands honor `DEVSTACK_PROJECT`/the persisted active project; with nothing set, behavior is byte-identical to today (single/first project). +- `devstack project new`/`edit` produce a config that passes `config validate`; `edit` preserves comments and key order in the target `devstack.yaml`. +- `devstack env` sets `env.raw` keys for local values and routes `--secret` keys through a provider with **no secret value in any written file** (CI-asserted, as in spec 04). +- The logo renders on `--help` and bare invocation but **never** under `--json`/`--quiet`; `status`/`up` show the context header, suppressed under `--json`/`--quiet`. +- `make ci` + `make determinism` stay green; the active-context migration is append-only; no existing generated artifact changes. + +## Open questions +- `[Q-CTX-SCOPE]` — persist the active context **per-Docker-context** (consistent with the ledger, and correct for WSL2's split daemons) vs. one global default. Lean: per-Docker-context. +- `[Q-YAML-ROUNDTRIP]` — the comment/order-preserving goccy AST editor for `project edit`/`env`: build it in `internal/scaffold`, or adopt a small third-party YAML-edit helper (must stay pure-Go/CGO-free). +- `[Q-PROMPT-COST]` — the prompt segment shells `devstack context` on every prompt render; measure cost and consider a cached, ledger-only fast path (or a background-refreshed cache file) so shells stay snappy. +- `[Q-USE-TARGET]` — should `use ` disambiguate workspace vs project by lookup order (project-in-current-workspace first, then registered workspaces), or require `use --project`/`use --workspace`? Lean: lookup order + a `--workspace`/`--project` disambiguator. + diff --git a/docs/specs/31-js-monorepo-templates-and-run.md b/docs/specs/31-js-monorepo-templates-and-run.md new file mode 100644 index 0000000..46a6fea --- /dev/null +++ b/docs/specs/31-js-monorepo-templates-and-run.md @@ -0,0 +1,77 @@ +# Spec 31 — JS/TS + monorepo templates & the task-graph runner (`devstack run`) + +**Module:** `templates/` (new built-in app + monorepo templates), `internal/template`/`internal/generate` (unchanged mechanism), `internal/config` (parse `tasks:`), `internal/task` (the DAG planner, new), `internal/run` (the executor, new — reusing `internal/hooks`), `internal/cli` (`run`) · **Milestone:** local-cloud DX lane (0.x beta line) · **Effort:** ~8w (templates ~3w + task runner ~5w, phased) · **ADR:** [D19](../DECISIONS.md) (task graph) + +## Purpose +Two gaps, one lane. **(1)** The JS/TS story is a single `node.vite` template ([`templates/node.vite/`](../../templates/node.vite)); there are no first-class templates for the frameworks developers actually run (Express, NestJS, Next.js, React+Vite, Bun) and **no dev-mode hot reload** guidance. **(2)** devstack orchestrates *containers and shared infra* but has **no task graph** — nothing like Turborepo/Nx that runs `build → test → dev` across the packages of a monorepo. Users asked for both: a real template library **and** "projects that run commands / work with monorepos like Turborepo". This spec adds the template library (with hot reload as a first-class requirement) and a **devstack-native task-graph runner**: a non-container `tasks:` kind plus `devstack run`, built on the existing `internal/hooks` command executor and the `ws` bounded-parallel pattern. The container model (long-running services on the shared network) is unchanged; tasks are a **new, orthogonal execution kind** for short-lived, dependency-ordered commands. + +Depends on the interactive DX layer only for polish (the `project`/`env` TUIs, [spec 30](30-interactive-dx-and-shell.md), can scaffold these templates); the template and runner work stand alone. + +## Decisions + +### The JS/TS + monorepo template library +- **New built-in templates, same `go:embed` mechanism as `node.vite`.** Add under `templates/`: `node.express`, `node.nestjs`, `node.next`, `react.vite`, `bun.app` (plus `bun.elysia`/`bun.hono` if cheap), and a `turborepo` monorepo template. Each is a directory with `template.yaml` + `build/Dockerfile` (+ `golden.yaml`), resolved by the existing engine ([spec 02](02-templating-and-generation.md)) — **no engine changes**. They are **project** templates (no `provides:`), so they're hidden from the shared-engine picker exactly like `php.*`/`node.*` today ([spec 22](22-init-wizard.md)). +- **A shared `node.base` parent via `extends`.** The Node templates `extends: node.base` (Node version arg, pnpm/npm corepack, workdir, non-root user), mirroring `php.laravel.nginx extends php.nginx`. Bun templates use a `bun.base`. Keeps the version/toolchain in one place; leaf templates add only their dev command + env. +- **Hot reload is a first-class requirement, not a note.** Every app template ships a dev-server `command` — `["npm","run","dev"]` / `["pnpm","dev"]` / `["bun","run","dev"]`, `["node","--watch",…]`, `nest start --watch`, `next dev`, `vite` — with `NODE_ENV: development`, and a **source bind-mount** so edits on the host hot-reload in the container. The bind-mount is expressed in the template `service.volumes` as a host-relative mount of the project dir onto the workdir, with an anonymous volume masking `node_modules` (so the image's installed deps aren't shadowed by the host). +- **WSL2/9p file-watching correctness.** On WSL2 and other 9p/networked mounts, inotify is unreliable (the same reality behind the `/mnt` refusal, [`internal/xdg`](../../internal/xdg)). Templates set `CHOKIDAR_USEPOLLING=true` / `WATCHPACK_POLLING=true` (Next/webpack) / Vite `server.watch.usePolling` via env, guarded so native Linux/macOS keep inotify. Documented per template and carried as `[Q-WSL-WATCH]`. +- **Deterministic + golden-gated.** Each template ships a `golden.yaml` so `make determinism` asserts byte-identical rendering, exactly as the existing engine templates do. + +### The task-graph runner (`tasks:` + `devstack run`) +- **A non-container "task" kind — the load-bearing new concept.** Tasks are **not** containers and **not** shared services; they are short-lived commands with dependency edges. A new optional `tasks:` block in `devstack.yaml` declares them; `devstack run ` plans and executes the DAG. This is the "run commands / monorepo orchestration" capability, kept deliberately separate from the container/services model so nothing about the deterministic compose pipeline or ref-counting changes. Codified in [D19](../DECISIONS.md). +- **Reuse the `internal/hooks` executor — do not build a second command runner.** Lifecycle hooks ([spec 11](11-lifecycle-hooks.md), `internal/hooks`) already run `host`/`exec` commands with `workdir`/`env`/`timeout`/`retries`/`onFailure`, never shell-split. A `Task` is the same command shape **plus `deps: []` and `watch: bool`**; `internal/run` drives them, calling the hooks executor per node. One executor, two callers (hooks-at-saga-phases and tasks-on-demand). +- **The DAG uses the `ws` bounded-parallel pattern.** Topological layers executed with `errgroup` + a `--parallel N` cap (default `min(8, 2*CPUs)`), identical to the multi-repo git commands ([`internal/cli/ws.go`](../../internal/cli/ws.go)). A cycle is a config-validation error (reuse the `${ref}` cycle-detection shape from [`internal/config`](../../internal/config)). Output is streamed per-task, color-keyed by task name (the `logs` gutter style). +- **Monorepo-awareness via `--filter` + package discovery.** For a `turborepo` (or any workspace-of-packages) project, `devstack run --filter ` scopes the DAG to a package and its dependents. Package discovery reads the JS workspace globs (`package.json#workspaces` / `pnpm-workspace.yaml`); the task graph is the product of `(package × task)` with edges from `deps` and inter-package dependencies. +- **Turborepo interop — two supported modes, one default.** A `turborepo` project can either **(a)** run `turbo run ` inside its container/toolchain (devstack shells one command; Turbo owns the graph), or **(b)** map its `turbo.json` pipeline into devstack `tasks:` so `devstack run` owns the graph and caching hooks. **Default = (a)** `turbo run` passthrough (least surprising, uses Turbo's own cache); (b) is opt-in for teams that want devstack to own scheduling. Fixed in `[Q-TURBO-DEFAULT]`/[D19](../DECISIONS.md). +- **`run:` targets, not just commands.** A task may target `run: host` (a process on the host, inheriting the user's toolchain) or `run: exec` (inside a named service container, via `compose exec -T` — the hooks `exec` path). Host is the default for build/test/lint; `exec` is for tasks that must run inside a service image. + +### Cross-cutting +- **No change to determinism, locking, or ref-counting.** Templates flow through the unchanged generate pipeline (golden-gated). `devstack run` takes **no flock** (it mutates no shared ledger/infra state — like `secrets ingest`); it only spawns processes/exec. Tasks never create or destroy containers or volumes (the never-recreate-a-stateful guard is not even in scope — tasks don't touch infra). +- **Additive, forward-tolerant config.** `tasks:` is optional; a `devstack.yaml` without it is unchanged. Unknown task keys are ignored (the [spec 27](27-resource-layer.md) `resources:` forward-tolerance precedent). + +## CLI surface +``` +devstack run [task2 ...] # plan + execute the task DAG for the active/target project + --project # target project (default: active/first — spec 30) + --filter # monorepo: scope to a package (+ its dependents) + --parallel # max concurrent tasks (default min(8, 2*CPUs)) + --watch # keep watch-mode tasks running (hot-reload dev servers) + --dry-run # print the resolved DAG + execution order, run nothing + --json # stream {task, package, status, code, ...} records +``` +```yaml +# devstack.yaml — the new optional tasks: block +tasks: + build: { run: host, command: ["pnpm", "build"] } + test: { run: host, command: ["pnpm", "test"], deps: [build] } + lint: { run: host, command: ["pnpm", "lint"] } + dev: { run: host, command: ["pnpm", "dev"], watch: true } + migrate: { run: exec, service: api, command: ["sh","-lc","pnpm migrate"], deps: [build] } +``` + +New built-in templates (project kind, no `provides:`): +``` +templates/node.base/ # shared parent: Node version, corepack, workdir, user +templates/bun.base/ # shared parent: Bun toolchain +templates/node.express/ # express dev server (node --watch / nodemon) +templates/node.nestjs/ # nest start --watch +templates/node.next/ # next dev (WATCHPACK_POLLING on WSL2) +templates/react.vite/ # vite dev server (usePolling on WSL2) +templates/bun.app/ # bun run dev (+ bun.elysia / bun.hono variants) +templates/turborepo/ # monorepo: turbo run passthrough (default) or tasks: mapping +``` + +## Acceptance criteria +- `devstack up` on a project using `node.next`/`react.vite` brings up a working dev server with **hot reload** — a host source edit is reflected in the container (native inotify; polling on WSL2). +- `devstack run test` executes `build` then `test` (dependency order); `devstack run build lint` runs both, `lint` in parallel with `build`'s independents; a dependency cycle fails `config validate`. +- `devstack run --filter ` in a `turborepo` project scopes the DAG to that package + dependents; `--dry-run` prints the order without executing. +- A `turborepo` project runs `turbo run build` by default (mode a); mapping `turbo.json` into `tasks:` (mode b) makes `devstack run` own the graph. +- Every new template ships a `golden.yaml`; `make determinism` stays green; a `devstack.yaml` without `tasks:` behaves exactly as today. +- `devstack run` takes no flock and touches no container/volume/ledger state. + +## Open questions +- `[Q-WSL-WATCH]` — the exact per-framework polling knobs (`CHOKIDAR_USEPOLLING`, `WATCHPACK_POLLING`, Vite `server.watch.usePolling`) and whether to auto-detect WSL2 (via `internal/xdg`) and inject them, vs. document-and-opt-in. +- `[Q-TURBO-DEFAULT]` — confirm `turbo run` passthrough as the default (uses Turbo's cache) with `turbo.json`→`tasks:` mapping opt-in; decide whether devstack ever wraps Turbo's remote cache. +- `[Q-TASK-CACHE]` — does `devstack run` gain content-hash task caching (skip unchanged tasks, à la Turbo), or stay a pure scheduler in v1? Lean: pure scheduler first; caching is a later phase. +- `[Q-PKGMGR]` — pnpm vs npm vs bun as the template default and how the runner detects the workspace package manager (lockfile sniff). +- `[Q-TASK-vs-HOOK]` — whether lifecycle hooks ([spec 11](11-lifecycle-hooks.md)) should be re-expressed as tasks over the same executor to avoid two config surfaces, or stay distinct (hooks = saga-phase-attached, tasks = on-demand). Lean: shared executor, distinct config surfaces. +