diff --git a/CHANGELOG.md b/CHANGELOG.md index 754e5c4..6061a89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to mstack are documented here. This project follows [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added + +- Expand curated packs with additional specialists, skills, invokable prompts, and + argv-only task recipes for every starter domain. +- Add backend/API, QA/testing, observability, and documentation packs. +- Add `mstack pack recommend` to cite repository evidence without installing packs. +- Add `mstack catalog --query` and `mstack task list --pack/--risk` discovery filters. +- Enforce task file preconditions and report missing executables as step failures. +- Add mobile, data-platform, CLI, and firmware worked examples. + +### Changed + +- Compose selected packs with first-id-wins merging so core resources cannot be + overwritten by a colliding pack identifier. +- Include pack specialists, skills, and prompts in `mstack catalog`. +- Show selected packs in `mstack status`. + ## [0.6.0] - 2026-07-25 ### Added diff --git a/README.md b/README.md index f731c12..b15ab8b 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ Use `mstack catalog` to discover the installed engineering resources and `mstack From a clean `main` checkout, run `./script.sh major`, `./script.sh minor`, or `./script.sh patch`. The script validates the package, updates all workspace versions, regenerates the command reference, runs the release checks, creates and pushes the `mstack-v` commit and tag, then opens npm's web login when needed and publishes `@imisbahk/mstack`. If publishing fails after the git push, rerun the printed `npm publish` command; do not create another version. -The current platform includes 15 AI environments, 19 specialist agents, 20 reusable skills, 4 automation hooks, 19 prompt packs, and 10 runtime templates. Its ten-phase workflow runs bounded specialist lanes in parallel where the AI environment supports subagents, while preserving one decision owner and safe sequential fallbacks elsewhere. It supports npm, pnpm, Yarn, and Bun, with versioned JSON output for automation. +The current platform includes 15 AI environments, 19 specialist agents, 20 reusable skills, 4 automation hooks, 19 prompt packs, 10 runtime templates, 14 curated capability packs, and policy-gated task recipes. Its ten-phase workflow runs bounded specialist lanes in parallel where the AI environment supports subagents, while preserving one decision owner and safe sequential fallbacks elsewhere. It supports npm, pnpm, Yarn, and Bun, with versioned JSON output for automation. Projects such as [g-stack](https://github.com/garrytan/gstack) already approach AI development environments with broad capability and configuration. I respect that approach. Build Like This makes a different trade-off: fewer choices, stronger defaults, and a specific product-to-production workflow. The purpose of mstack is not to expose every possible setup. It is to get a repository ready for the process I would personally use. @@ -141,6 +141,10 @@ Each example begins with product and architecture documents. There is no impleme - [SaaS: InvoiceFlow](examples/saas/README.md) — design tenancy, payments, and operational reliability before they become incidents. - [Startup: CareCircle](examples/startup/README.md) — validate risky assumptions before automating the business. - [Web application: LocalBoard](examples/web-app/README.md) — keep a conventional application simple and modular. +- [Mobile application: FieldNote](examples/mobile-app/README.md) — treat permissions, offline queues, and store constraints as product decisions. +- [Data platform: SignalWell](examples/data-platform/README.md) — make data contracts, late events, and evaluation part of architecture. +- [CLI tool: RepoLens](examples/cli-tool/README.md) — keep command contracts stable, safe, and automation-friendly. +- [Firmware device: DockSense](examples/firmware-device/README.md) — keep hardware fail-safes explicit and bench claims evidence-gated. ## AI agents in this repository diff --git a/docs/architecture.md b/docs/architecture.md index 713e3d5..064218b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -58,4 +58,4 @@ Start from [`templates/architecture.template.md`](../templates/architecture.temp ## Current mstack architecture decision -Capability packs are a curated in-process registry in the CLI. Each pack declares metadata, a normal `IntegrationSpec` contribution, and declarative argv-only task recipes. `ai setup` composes the core spec with manifest-selected packs, then uses existing adapters, planner, reconciliation, and ownership mechanisms. The project manifest stores the desired pack ID/version set; runtime manifests continue to own rendered files. Tasks execute literal argument vectors with `shell: false`, apply the configured policy before execution, and emit per-step results. See [ADR-0003](decisions/0003-curated-capability-packs-and-task-recipes.md). +Capability packs are a curated in-process registry in the CLI. Each pack declares metadata, a normal `IntegrationSpec` contribution, and declarative argv-only task recipes. `ai setup` composes the core spec with manifest-selected packs, then uses existing adapters, planner, reconciliation, and ownership mechanisms. Composition is first-id-wins so core resources are never overwritten by a pack with a colliding identifier. The project manifest stores the desired pack ID/version set; runtime manifests continue to own rendered files. Tasks execute literal argument vectors with `shell: false`, apply file preconditions and the configured policy before execution, and emit per-step results. `pack recommend` reads well-known marker files only and does not write. See [ADR-0003](decisions/0003-curated-capability-packs-and-task-recipes.md). diff --git a/docs/cli/README.md b/docs/cli/README.md index badaf69..7094893 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -48,7 +48,10 @@ Existing planning documents are preserved. `--force` is the only path that inten | `mstack explain` | Walk through the repository's installed workflow | | `mstack ai setup` | Configure detected or selected AI coding runtimes | | `mstack ai list` | Inspect runtime support and detection | -| `mstack catalog` | Discover agents, skills, prompts, hooks, and templates | +| `mstack catalog` | Discover agents, skills, prompts, hooks, templates, packs, and task recipes | +| `mstack pack` | List, inspect, recommend, add, and remove curated capability packs | +| `mstack task` | Inspect and run policy-gated argv-only task recipes | +| `mstack agent` | List installed specialists and runtime invocation guidance | | `mstack validate` | Verify planning readiness, ownership, and runtime integrity | | `mstack doctor` | Diagnose runtime, repository, permissions, and manifest health | | `mstack config` | Inspect or change layered configuration | diff --git a/docs/cli/command-reference.md b/docs/cli/command-reference.md index 5cc1771..358445c 100644 --- a/docs/cli/command-reference.md +++ b/docs/cli/command-reference.md @@ -21,8 +21,8 @@ Commands: invocation guidance; does not execute models ai configure AI coding environments for this repository - catalog [options] [kind] discover agents, skills, prompts, hooks, and - templates + catalog [options] [kind] discover packs, agents, skills, prompts, + hooks, templates, and task recipes config inspect or change mstack configuration doctor [options] inspect the runtime and current project explain [options] walk through Misbah's Build Like This workflow @@ -172,15 +172,17 @@ Options: ```text Usage: mstack catalog [options] [kind] -discover agents, skills, prompts, hooks, and templates +discover packs, agents, skills, prompts, hooks, templates, and task recipes Arguments: - kind limit results to one resource kind (choices: "packs", "agents", - "skills", "prompts", "hooks", "templates", "task-recipes") + kind limit results to one resource kind (choices: "packs", + "agents", "skills", "prompts", "hooks", "templates", + "task-recipes") Options: - -h, --help display help for command - --json print a versioned JSON catalog (default: false) + -h, --help display help for command + --json print a versioned JSON catalog (default: false) + --query filter by id, description, or pack ``` ## mstack pack @@ -199,6 +201,8 @@ Commands: help [command] display help for command info [options] show pack resources and prerequisites list [options] show curated packs + recommend [options] suggest curated packs from repository evidence + without installing them remove [options] remove selected packs and reconcile owned runtime files update [options] reconcile selected packs through ai setup @@ -228,6 +232,18 @@ Options: --json print a versioned JSON result (default: false) ``` +## mstack pack recommend + +```text +Usage: mstack pack recommend [options] + +suggest curated packs from repository evidence without installing them + +Options: + -h, --help display help for command + --json print a versioned JSON result (default: false) +``` + ## mstack pack add ```text @@ -309,8 +325,11 @@ Usage: mstack task list [options] list task recipes Options: - -h, --help display help for command - --json print a versioned JSON result (default: false) + -h, --help display help for command + --json print a versioned JSON result (default: false) + --pack limit results to one pack + --risk limit results to one risk class (choices: "read-only", + "working-tree", "destructive", "remote") ``` ## mstack task show diff --git a/docs/decisions/0003-curated-capability-packs-and-task-recipes.md b/docs/decisions/0003-curated-capability-packs-and-task-recipes.md index 68fd4f8..5bad1ac 100644 --- a/docs/decisions/0003-curated-capability-packs-and-task-recipes.md +++ b/docs/decisions/0003-curated-capability-packs-and-task-recipes.md @@ -15,5 +15,7 @@ Task recipes are data, not arbitrary shell programs: each step is a literal argv - Existing runtime adapter and reconciliation behavior remains the sole file-ownership mechanism. - v1 excludes third-party pack loading and high-consequence publishing/deployment recipes. +- Pack recommendation inspects well-known marker files and does not change selection or install resources. +- Composition is first-id-wins so a pack cannot overwrite a core resource id. - Pack removal changes desired selection and requires normal AI setup reconciliation to remove stale owned runtime resources safely. - Model execution remains visible to and controlled by the user’s chosen runtime. diff --git a/docs/features.md b/docs/features.md index 9e4fe84..c96ccc6 100644 --- a/docs/features.md +++ b/docs/features.md @@ -29,6 +29,6 @@ The biggest mistake I want to prevent is a feature becoming a self-justifying pr | Feature | User need | Status | Owner | Specification | Outcome metric | | --- | --- | --- | --- | --- | --- | -| Extensible AI engineering packs and safe task recipes | Teams need domain-specific AI guidance and repeatable local workflows without unreviewed model execution or shell injection. | in progress | mstack maintainers | [AI engineering packs](features/ai-engineering-packs.md) | A compatible runtime renders selected packs deterministically; every non-read-only task has a recorded policy decision. | +| Extensible AI engineering packs and safe task recipes | Teams need domain-specific AI guidance and repeatable local workflows without unreviewed model execution or shell injection. | in progress | mstack maintainers | [AI engineering packs](features/ai-engineering-packs.md) | A compatible runtime renders selected packs deterministically; every non-read-only task has a recorded policy decision; recommend cites repository evidence without installing packs. | Small, obvious work can remain in `product.md`, an issue, and tests. More documentation is not automatically more disciplined. The right documentation makes the next decision clearer. diff --git a/docs/features/ai-engineering-packs.md b/docs/features/ai-engineering-packs.md index f6e0706..4fa9061 100644 --- a/docs/features/ai-engineering-packs.md +++ b/docs/features/ai-engineering-packs.md @@ -4,22 +4,27 @@ Users of `mstack` can opt into a curated domain pack, discover its specialists and tasks, and safely run a small catalog of local argv-only task recipes. `mstack ai setup` remains the lightweight core setup and renders selected compatible packs; it does not execute models or coordinate inference. -The first release includes starter packs for repository intelligence, software/web, systems, embedded/firmware, robotics, data/ML, mobile, games/graphics, infrastructure/security, and CLI/devtools. Repository intelligence produces evidence-backed maps and explanations, not autonomous code edits. +The catalog includes starter packs for repository intelligence, software/web, systems, embedded/firmware, robotics, data/ML, mobile, games/graphics, infrastructure/security, CLI/devtools, backend/API, QA/testing, observability, and documentation. Repository intelligence produces evidence-backed maps and explanations, not autonomous code edits. `mstack pack recommend` inspects well-known repository markers and suggests packs with cited evidence; it never installs them. + +Every curated pack ships specialists, skills, an invokable prompt, and policy-gated task recipes. Domain recipes stay argv-only and fail before execution when declared file preconditions are missing. ## Acceptance criteria -- `mstack pack list`, `info`, `add`, `remove`, and `update` expose only bundled curated packs. +- `mstack pack list`, `info`, `add`, `remove`, `update`, and `recommend` expose only bundled curated packs. - Selected pack IDs and versions are persisted in the mstack manifest and are composed into normal runtime rendering. -- `mstack catalog` includes packs and task recipes, and `mstack agent` only describes installed specialists and invocation guidance. -- `mstack task` accepts declarative literal argv steps only, supports ordered failure-stop execution, dry run, JSON records, timeout, and policy confirmation. +- `mstack catalog` includes packs, pack specialists, pack skills, pack prompts, and task recipes, and `mstack agent` only describes installed specialists and invocation guidance. +- `mstack catalog --query` and `mstack task list --pack/--risk` filter the same source-backed inventory. +- `mstack task` accepts declarative literal argv steps only, supports ordered failure-stop execution, dry run, JSON records, timeout, file preconditions, and policy confirmation. - Default `balanced` policy runs read-only recipes directly and requires explicit confirmation for working-tree, destructive, and remote work. +- Duplicate pack, specialist, skill, prompt, and task IDs are rejected before a pack can be registered. ## Non-goals - Remote, npm, Git, or marketplace pack installation. - Hosting inference, selecting models, or autonomously dispatching agents. - Shell-script recipes, implicit commit/push, packaging, publishing, or deployment recipes. +- Automatic pack installation from repository heuristics. ## Failure and recovery -Unknown packs/tasks, missing initialization, declined confirmation, command errors, and timeouts fail with a location-specific result. Recipe execution stops at its first failed step. Pack removal changes desired selection; `mstack ai setup` reconciles runtime-owned artifacts under existing ownership and drift protections. +Unknown packs/tasks, missing initialization, missing recipe preconditions, declined confirmation, missing executables, command errors, and timeouts fail with a location-specific result. Recipe execution stops at its first failed step. Pack removal changes desired selection; `mstack ai setup` reconciles runtime-owned artifacts under existing ownership and drift protections. Recommendation is read-only and does not change the manifest. diff --git a/docs/features/cli-validation-and-catalog.md b/docs/features/cli-validation-and-catalog.md index 282f19f..ca79b45 100644 --- a/docs/features/cli-validation-and-catalog.md +++ b/docs/features/cli-validation-and-catalog.md @@ -11,7 +11,8 @@ Developers can see what the Build Like This runtime contains without inspecting Included: -- `mstack catalog [kind]` for agents, skills, prompts, hooks, and templates; +- `mstack catalog [kind]` for agents, skills, prompts, hooks, templates, packs, and task recipes; +- `mstack catalog --query` for id and description filtering; - stable, versioned JSON output for catalog automation; - `mstack validate [directory]` for planning-document readiness, mstack manifest integrity, and AI runtime verification; - `--strict` validation that treats warnings as release-blocking; @@ -30,7 +31,7 @@ Not included: ### Catalog -`mstack catalog` lists every resource grouped by kind and reports exact totals. An optional kind limits output to `agents`, `skills`, `prompts`, `hooks`, or `templates`. Catalog reads the versioned runtime definitions directly, so documentation and terminal output cannot maintain a separate inventory. +`mstack catalog` lists every resource grouped by kind and reports exact totals. An optional kind limits output to `packs`, `agents`, `skills`, `prompts`, `hooks`, `templates`, or `task-recipes`. `--query` filters those results by id, description, or pack. Catalog reads the versioned runtime definitions and the curated pack registry directly, so documentation and terminal output cannot maintain a separate inventory. ### Validation diff --git a/docs/product.md b/docs/product.md index 61775db..c47352f 100644 --- a/docs/product.md +++ b/docs/product.md @@ -60,4 +60,4 @@ Start from [`templates/product.template.md`](../templates/product.template.md). ## Current mstack product decision -For mstack users who work across different engineering domains, optional curated capability packs provide specialist guidance and safe local task recipes without making the default setup heavyweight. The boundary is intentional: mstack installs and reconciles runtime-native resources, while the selected AI runtime owns model inference and any agent coordination. See [the feature specification](features/ai-engineering-packs.md). +For mstack users who work across different engineering domains, optional curated capability packs provide specialist guidance, invokable prompts, and safe local task recipes without making the default setup heavyweight. Pack recommendation cites repository evidence and leaves selection explicit. The boundary is intentional: mstack installs and reconciles runtime-native resources, while the selected AI runtime owns model inference and any agent coordination. See [the feature specification](features/ai-engineering-packs.md). diff --git a/examples/cli-tool/README.md b/examples/cli-tool/README.md new file mode 100644 index 0000000..a4984a5 --- /dev/null +++ b/examples/cli-tool/README.md @@ -0,0 +1,31 @@ +# CLI Tool Example: RepoLens + +RepoLens is a fictional local CLI that maps a repository's modules, entry points, and documented test commands so a developer or agent can orient without guessing. + +I included a CLI example because developer tools are products with contracts. Stdout, JSON, exit codes, and default safety are the user interface. A mapper that writes files, runs tests, or changes format without a version bump is a breaking change, even if the code still "works." + +## What this example demonstrates + +- treat argv, stdout, stderr, and exit codes as a versioned contract; +- keep JSON output stable and explicit so scripts and agents can rely on it; +- default to non-destructive, read-only behavior; +- distinguish observed repository facts from inferred structure; +- skip network, installs, and test execution until the user asks. + +## Documents before implementation + +1. [`product.md`](product.md) defines the local user, the map they need, and what the tool must never do by default. +2. [`architecture.md`](architecture.md) describes command contracts, scanners, output schemas, and failure behavior. + +## How I would deliver it + +1. Watch five developers (or agents) onboard onto an unfamiliar repo and note where they guess. +2. Define the map schema, command names, and exit codes. +3. Implement read-only detectors for modules and test commands. +4. Freeze `--json` fixtures before polishing human output. +5. Add `--print-tests` display; do not execute tests in the first release. +6. Pilot on this repository and two others with different layouts before calling the contract stable. + +## Suggested mstack packs + +Pack ids to consider later: `cli-devtools` and `repository-intelligence`. `mstack pack recommend` may cite repository evidence for them; it does not install packs. Adding a pack remains an explicit choice. diff --git a/examples/cli-tool/architecture.md b/examples/cli-tool/architecture.md new file mode 100644 index 0000000..c844377 --- /dev/null +++ b/examples/cli-tool/architecture.md @@ -0,0 +1,85 @@ +# Architecture: RepoLens + +> Status: Accepted for private preview +> Owner: Developer tools engineer +> Product source: [`product.md`](product.md) + +**Playbook lesson:** the architecture is a local pipeline with a frozen +command contract. Scanners may change; stdout, JSON schema, and the rule that +defaults never write may not change casually. + +## Summary + +RepoLens is a single local Node.js TypeScript CLI. There is no server, daemon, or hosted component. A modular monolith inside one process owns detection, mapping, and rendering. Package managers, git, and test runners are not invoked unless a later documented command exists; v1 only reads files. + +```mermaid +flowchart LR + User --> CLI[RepoLens CLI] + CLI --> FS[Working tree] + CLI --> Stdout[Text or JSON] + CLI --> Stderr[Progress and warnings] +``` + +## Structure and dependencies + +```text +src/ +├── cli/ # Argv parsing, help, exit codes +├── modules/ +│ ├── detect/ # Manifest and layout detectors +│ ├── map/ # Module graph from observed facts +│ └── render/ # Text and JSON writers +├── contracts/ # Runtime schemas for argv and output +└── fs/ # Bounded, follow-link-safe reader +test/fixtures/ # Golden JSON and fake repositories +``` + +The CLI layer maps argv to one command, validates flags, and chooses an exit code. `detect` returns observed facts with file citations. `map` may add inferred nodes, always labeled. `render` is the only writer, and it writes only to stdout/stderr. No module imports a package-manager or test-runner SDK in v1. + +## Data and state + +RepoLens is stateless across runs. It does not write cache files by default. In-memory records: + +- `Fact`: path, kind (`package_manifest`, `ci_workflow`, `entry_point`, `test_script`), citation, observed. +- `ModuleRecord`: name, root path, entry points, test commands, `evidence: observed | inferred`. +- `MapDocument`: `schemaVersion`, repository root, modules, warnings, generated-at. + +Ignore rules skip `node_modules`, `.git`, build output, and an optional `.repolensignore`. Traversal has a file-count and depth cap; exceeding it is a warning plus partial map, not a hang. + +Secrets: files that look like `.env` or key material are not opened for mapping. Their paths may appear as skipped. + +## Command contract + +| Command | Default behavior | Output | +| --- | --- | --- | +| `repolens map` | Read-only map | Text on stdout | +| `repolens map --json` | Same scan | `MapDocument` JSON on stdout | +| `repolens map --help` | No scan | Help text | + +Flags: `--root` (default cwd), `--json`, `--quiet` (no stderr progress). There is no `--write`, `--fix`, or `--run-tests` in v1. + +Exit codes: `0` map produced, `2` invalid argv, `3` root is not a recognized repository, `1` unexpected error. JSON mode still uses these codes; errors that prevent a document go to stderr as text, not a second JSON schema, unless `--json` can emit a versioned `error` document without breaking success parsers—preview uses stderr errors plus non-zero exit. + +`--json` compatibility: `schemaVersion` is an integer. Additive fields are allowed. Renames and type changes require a version bump and a changelog entry. Golden fixtures in CI fail on accidental churn. + +Human text may change more freely; scripts must use `--json`. + +## Safety and trust boundaries + +The filesystem is untrusted. The reader does not follow symlinks outside `--root`, does not execute files, and does not interpolate file contents into a shell. Default commands make no network requests. + +Stdout is data. Progress, warnings, and deprecation notes go to stderr so pipes stay clean. Unicode and large paths are escaped in JSON. + +If detection is uncertain, the map emits a warning and labels inference. It does not invent a test command from folklore (`npm test` is included only when a manifest or CI file contains it). + +## Deployment, testing, and operation + +The artifact is an npm CLI package run with `npx` or a local binary. There is no Render or Vercel topology. Releases are versioned with the JSON schema. + +CI runs unit tests for detectors, contract tests against golden JSON, and integration tests on fixture repositories including a repo with a stale README and a correct CI test command. A write-guard test fails if default `map` creates or modifies files. + +Operation is local: if a scan hits the file cap, the user sees a stderr warning and a partial map. There are no crash-reporting hooks in preview. + +## Evolution triggers + +Add language-specific detectors when fixture recall for test commands falls below the 90% target. Add an explicit `repolens test --dry-run` only after the map contract is stable; execution remains opt-in. Add a cache directory only behind a flag when measured scan time exceeds five seconds on the published fixture. Do not add a daemon, cloud account, or auto-update that mutates the tree. Extract packages internally if detector ownership splits; do not split into services. diff --git a/examples/cli-tool/product.md b/examples/cli-tool/product.md new file mode 100644 index 0000000..447000b --- /dev/null +++ b/examples/cli-tool/product.md @@ -0,0 +1,82 @@ +# Product: RepoLens + +> Status: Approved for private preview +> Owner: Developer tools lead +> Last updated: 2026-08-13 + +**Playbook lesson:** a CLI's product is its command contract. Stable stdout +and JSON, honest inference labels, and non-destructive defaults matter more +than extra subcommands. + +## Vision + +A developer or coding agent can open an unfamiliar repository and quickly see which modules exist, where entry points live, and which commands the project already uses to test them—without the tool changing the tree. + +## Problem + +READMEs go stale. Test commands hide in CI YAML or package scripts. Agents and new contributors spend the first session guessing `npm test` versus `pnpm vitest` versus a Makefile target, and they infer module boundaries from folder names that are not real packages. + +Observation from five internal repositories: three documented a test command that CI no longer ran; four had modules with no README pointer. Hypothesis: a local read-only map will be used in onboarding and in agent context if it is fast, accurate enough, and never writes. Willingness to install another CLI is still a hypothesis. + +## Target users and personas + +**Primary user:** a software engineer opening a repository they do not own, including during incident response or review. + +**Secondary user:** an AI coding agent that needs a cited map rather than a guessed architecture. + +**Persona — incoming engineer:** will run one command, wants a scannable summary in seconds, needs a `--json` form for scripts, and treats unexpected file writes or test runs as a trust failure. + +## User needs + +| Need | Evidence | +| --- | --- | +| See modules and entry points with file citations | Observed onboarding delay; high confidence | +| Discover the test command the repo actually declares | Observed stale READMEs; high confidence | +| Machine-readable output that does not churn | Agent/script users; high importance | +| No writes, installs, or test runs by default | Trust requirement; treat as constraint | +| Know what was inferred vs read from config | Hypothesis; high importance | + +## First-release features + +| Feature | User outcome | Acceptance summary | +| --- | --- | --- | +| `repolens map` | Orient without spelunking | Prints modules, entry points, and source citations | +| Test command discovery | Know how this repo tests | Shows commands found in manifests and CI, not executed | +| `--json` | Scripts and agents parse one schema | Versioned document; unknown fields may be added, not removed | +| Human stdout | Quick reading in a terminal | Default format is text; progress and warnings go to stderr | +| Inference labels | Avoid false certainty | Each module record is `observed` or `inferred` | +| Dry defaults | Tree is unchanged | Exit 0 mapping never creates, edits, or deletes files | + +## Non-goals + +- Editing code, applying refactors, installing dependencies, starting services, or running the discovered test commands in v1. +- Cloud indexing, hosted SaaS, secret scanning as a product, or replacing language servers. +- Perfect language coverage. First release targets Node package manifests, lockfiles, CI YAML, and well-known `src/` package layouts, plus a documented extension point. + +## Success metrics + +| Metric | Preview target | +| --- | --- | +| Time to first useful map on a mid-size repo | Median under 5 seconds | +| Test commands matching what CI actually invokes | At least 90% on a 10-repo fixture set | +| Accidental working-tree writes in default commands | 0 | +| `--json` fixture compatibility | No breaking field rename without a schema version bump | +| Preview users who run it twice in a week | At least 6 of 10 | + +Guardrails: default commands make no network calls; output never prints secret values found in files; inferred structure cannot be presented as observed. + +## Core journey + +1. A developer clones a service and runs `repolens map` at the repository root. +2. Stderr may show scan progress; stdout prints modules with paths and whether each fact is observed. +3. They run `repolens map --json` and pipe it to an agent or script. +4. The JSON lists `testCommands` discovered from `package.json` and CI, with file citations. +5. They run those tests themselves. RepoLens does not. +6. A later RepoLens version adds a field to JSON without breaking existing parsers. + +## Constraints and risks + +- The working tree is untrusted input. Symlinks, huge trees, and generated folders need bounds and ignores. +- Hypothesis: citing files will beat a prettier but uncited tree. If citations are wrong, trust collapses. +- Exit codes are part of the product: `0` success, `2` usage error, `3` unsupported repo layout, `1` unexpected failure. +- Preview is local-only. Do not add telemetry that phones home from a default map. diff --git a/examples/data-platform/README.md b/examples/data-platform/README.md new file mode 100644 index 0000000..4ad6d7a --- /dev/null +++ b/examples/data-platform/README.md @@ -0,0 +1,31 @@ +# Data Platform Example: SignalWell + +SignalWell is a fictional internal metrics warehouse for a 40-person operations team that needs trustworthy service and workflow metrics, including when events arrive late. + +I included a data-platform example because notebooks and dashboard tools do not make a warehouse. Event contracts, late-arriving facts, restatement, and evaluation of metric correctness are architecture. Without them the team argues over screens instead of operating the product. + +## What this example demonstrates + +- define producer-consumer data contracts before building charts; +- treat late events as a first-class state, not a pipeline bug to ignore; +- evaluate freshness, completeness, and metric definitions as release gates; +- keep a modular monolith for a 40-person internal audience rather than a lakehouse; +- separate operational metrics from ad-hoc exploration so notebooks cannot silently redefine truth. + +## Documents before implementation + +1. [`product.md`](product.md) names the ops users, the trust problem, and the first metrics that must be right. +2. [`architecture.md`](architecture.md) defines ingest, contracts, restatement, serving, and evaluation. + +## How I would deliver it + +1. Inventory the five metrics on-call actually uses and where they disagree today. +2. Write event contracts and late-arrival rules with producer owners. +3. Implement ingest, storage, and one restatable metric serving path. +4. Add freshness and completeness checks before any dashboard. +5. Connect a narrow internal UI to the serving contract. +6. Pilot with on-call, then add metrics only when the previous definition stays stable. + +## Suggested mstack packs + +Pack ids to consider later: `data-ml`, `backend-api`, and `observability`. `mstack pack recommend` may cite repository evidence for them; it does not install packs. Adding a pack remains an explicit choice. diff --git a/examples/data-platform/architecture.md b/examples/data-platform/architecture.md new file mode 100644 index 0000000..376e77c --- /dev/null +++ b/examples/data-platform/architecture.md @@ -0,0 +1,79 @@ +# Architecture: SignalWell + +> Status: Accepted for internal pilot +> Owner: Data platform lead +> Product source: [`product.md`](product.md) + +**Playbook lesson:** contracts, watermarks, restatement, and evaluation are +modules with owners. A notebook is an untrusted client of those modules, not +the system of record. + +## Summary + +SignalWell is a modular monolith: an Express TypeScript ingest and serving API, a transform worker, PostgreSQL through Prisma, and a small Next.js internal UI. Five official metrics share one database. Event schemas, late-arrival rules, and evaluation fixtures live in versioned repository artifacts. No separate streaming cluster, lake, or microservice is introduced for a 40-person ops audience. + +```mermaid +flowchart LR + Producer --> Ingest[Express ingest] + Ingest --> DB[(PostgreSQL)] + Worker[Transform worker] --> DB + Eval[Contract evaluation] --> DB + Oncall --> Web[Internal UI] + Web --> Serve[Express serving] + Serve --> DB + Notebooks --> Replica[(Read replica)] +``` + +## Structure and dependencies + +```text +apps/api/src/modules/ +├── contracts/ # Schema registry, owners, compatibility +├── ingest/ # Validation, idempotent append +├── warehouse/ # Durable events and watermarks +├── metrics/ # Official definitions and serving +└── evaluation/ # Fixtures, freshness, completeness +apps/worker/ # Restatement and check entry points +apps/web/ # Metric pages and contract status +packages/contracts/ # Runtime event and metric schemas +packages/database/ # Prisma and repositories +``` + +HTTP handlers validate and call module services. Only `ingest` writes raw events. Only `metrics` publishes official values. `evaluation` may read warehouse tables and write check results; it cannot change metric formulas. Notebooks have replica credentials and no serving-table grants. Repositories are the only layer that import Prisma. + +## Data and state + +- `EventContract`: name, version, owner, schema hash, compatibility mode, status. +- `EventRecord`: contract version, producer, occurred-at, received-at, payload, idempotency key. +- `Watermark`: metric, window, open/closed, late-event count. +- `MetricDefinition`: formula, grain, owner, unit, restatement policy, source contracts. +- `MetricValue`: definition version, window, value, completeness, restated-at. +- `EvaluationRun`: fixture set version, pass/fail, failing contract or check. + +Events are append-only. Idempotency is `(producer, idempotency key)` unique. `occurred_at` is the fact time; `received_at` is ingest time. Late means `received_at` after the window end and still inside the metric's declared watermark. After the watermark closes, later events are stored but do not change that window; they increment an excluded-late counter for operators. + +## API and protocol contract + +`POST /v1/events` accepts a contract name, version, idempotency key, occurred-at, and payload. Unknown contracts, schema failures, and incompatible versions return `422 contract_rejected` with a stable code. Successful ingest returns `202` and the assigned event id. + +`GET /v1/metrics/:id` returns the official value, definition version, watermark state, completeness, and last restatement. It never returns a notebook SQL string as the definition. + +`POST /v1/evaluation/runs` is operator-triggered or scheduled. Breaking contract changes fail CI when fixtures are updated without a version bump. + +Mutations that ingest events or close watermarks require idempotency keys. Serving schemas are independent of Prisma models and of producer SDK types. + +## Auth, safety, and untrusted inputs + +Internal SSO identifies users. Producers authenticate with scoped ingest tokens bound to allowed contract names. On-call can read metrics; only metric owners can change definitions, through reviewable repository changes rather than a live SQL editor. + +Payloads are untrusted. Ingest enforces size limits, required fields, and a denylist for secret-like keys. Logs store contract, ids, and outcome codes, not raw payloads. PII does not belong in official events; rejected payloads that look like personal data increment a redaction metric. + +## Deployment, testing, and operation + +One API service, one worker, and managed PostgreSQL deploy together. A read replica, if enabled, is for exploration only. CI runs contract compatibility tests, restatement unit tests, warehouse integration tests, and the evaluation fixtures for the five metrics. + +Alerts: ingest rejection spikes, watermark stuck open, completeness below the metric's threshold, restatement lag, evaluation failures, and database saturation. If the worker is down, serving continues to show last values with an explicit stale watermark rather than a blank or a guessed number. + +## Evolution triggers + +Add partitioning or a dedicated event store when measured ingest volume or retention cost exceeds the documented PostgreSQL budget. Add a queue in front of ingest only if producer timeouts become a measured incident. Extract a metric-serving replica when on-call read latency exceeds the SLO while ingest load is the cause. Do not introduce a lakehouse or stream processor to make the architecture look like a data platform. Notebooks never become an official publish path. diff --git a/examples/data-platform/product.md b/examples/data-platform/product.md new file mode 100644 index 0000000..6922138 --- /dev/null +++ b/examples/data-platform/product.md @@ -0,0 +1,81 @@ +# Product: SignalWell + +> Status: Approved for internal pilot +> Owner: Operations lead +> Last updated: 2026-08-13 + +**Playbook lesson:** a metrics warehouse is a product with contracts and +failure behavior. Late events, disputed definitions, and unevaluated +dashboards are not notebook problems; they are architecture. + +## Vision + +The operations team can answer "is this service healthy for customers right now?" from a small set of metrics whose meaning does not change when a job finishes late or someone edits a query. + +## Problem + +A 40-person company currently mixes application logs, spreadsheet rollups, and a few Grafana panels. When a worker finishes after the hour, some charts under-count and others ignore the late data. Incident reviews spend time reconciling numbers. Engineers paste SQL into notebooks that become unofficial truth. + +Observation from two incident reviews: on-call trusted a green error-rate panel that had dropped events arriving more than 15 minutes late. Observation from five engineer interviews: three maintain a private query that disagrees with the shared dashboard. Hypothesis: named contracts and restatement will reduce reconciliation time; that is what the pilot must test. + +## Target users and personas + +**Primary user:** an on-call operations engineer who needs a small number of trustworthy service metrics during and after an incident. + +**Secondary users:** producing-service engineers who emit events, and the ops lead who owns metric definitions. + +**On-call persona:** checks a handful of metrics under time pressure, needs to know freshness and completeness, and succeeds when a late backfill changes a number visibly rather than silently. + +## User needs + +| Need | Evidence | +| --- | --- | +| One owned definition per pilot metric | Observed disagreement; high confidence | +| See when a number is stale or incomplete | Observed in incidents; high confidence | +| Include late events without pretending they were on time | Observed; high confidence | +| Know which producer broke a contract | Hypothesis; high importance | +| Explore freely without overwriting official metrics | Observed notebook drift; medium confidence | + +## First-release features + +| Feature | User outcome | Acceptance summary | +| --- | --- | --- | +| Versioned event contracts | Producers and consumers share a schema | Breaking field changes are rejected at ingest | +| Late-arrival window | Ops knows what is still open | Each metric declares a watermark and restatement rule | +| Official metric serving | On-call reads one number | Five named metrics with owners, formulas, and units | +| Freshness and completeness | Trust is inspectable | UI shows last event time, expected vs received counts, and late restates | +| Contract evaluation | Bad producers fail visibly | Replay fixtures and daily checks run before the metric is marked healthy | +| Read-only exploration | Ad-hoc questions stay ad-hoc | Notebooks may query replicas; they cannot publish official metrics | + +## Non-goals + +- Customer-facing analytics, advertising attribution, a self-serve warehouse for every team, real-time bidding, ML training, a general data lake, or replacing OLTP systems. +- Streaming-first infrastructure for the pilot. Batch plus a declared watermark is enough for the five metrics. + +## Success metrics + +| Metric | Eight-week internal pilot target | +| --- | --- | +| On-call uses SignalWell as first metric source during incidents | At least 8 of 10 sampled incidents | +| Reconciliation arguments about the five official metrics | Reduce to zero unresolved definition disputes | +| Late events included within the declared watermark | 100% of accepted events in evaluation fixtures | +| Contract-breaking producer deploys reaching official metrics | 0 | +| Time to explain a metric's formula and owner | Under 2 minutes from the metric page | + +Guardrails: official metrics never silently drop late-but-in-window events; exploration queries cannot mutate serving tables; PII and secrets do not enter event payloads; query cost stays within the stated internal budget. + +## Core journey + +1. A producer engineer publishes `checkout.completed.v1` with an owner and schema. +2. Ingest validates and stores the event, including ones that arrive 20 minutes late. +3. The transform restates hourly checkout-success for that window. +4. On-call opens the metric, sees it restated, and sees completeness 99.4% with an open watermark. +5. A notebook user explores a replica and cannot publish a competing official number. +6. Evaluation fixtures fail a breaking field rename before it reaches serving. + +## Constraints and risks + +- Forty people and five metrics do not justify a distributed lakehouse; complexity must be earned by measured volume or ownership split. +- Hypothesis: producers will version events if rejection is loud and early. If they bypass ingest, the metric cannot be official. +- Retention follows existing operational-log policy; the warehouse is not an unbounded archive. +- This is an internal tool. Availability matters for incidents, but it is not a customer data product. diff --git a/examples/firmware-device/README.md b/examples/firmware-device/README.md new file mode 100644 index 0000000..bd6a62a --- /dev/null +++ b/examples/firmware-device/README.md @@ -0,0 +1,31 @@ +# Firmware Device Example: DockSense + +DockSense is a fictional battery-powered occupancy sensor for marina slips. It advertises occupied, vacant, or unknown over BLE to a nearby gateway, with a small firmware image and a modest backend for operators. + +I included a firmware example because hardware boundaries and fail-safe defaults are product promises. Battery life, detection accuracy, and radio range cannot be claimed from a schematic. A slip reported vacant because the battery died is a product failure, not an ops footnote. + +## What this example demonstrates + +- define fail-safe occupancy when the sensor, radio, or battery fails; +- keep firmware, gateway, and backend in a modular monolith with explicit hardware adapters; +- treat BLE payloads and image size as contracts; +- refuse performance and accuracy claims without bench evidence; +- constrain OTA and debug access so a bad image cannot brick the dock silently. + +## Documents before implementation + +1. [`product.md`](product.md) defines marina operators, the occupancy states, and what the first hardware pilot will not claim. +2. [`architecture.md`](architecture.md) describes firmware duties, BLE protocol, fail-safe timeouts, and verification gates. + +## How I would deliver it + +1. Walk a marina and record how slips are checked today, including night and weather. +2. Define occupied, vacant, unknown, and last-known-stale as product states. +3. Freeze the BLE advertisement contract and image-size budget. +4. Implement firmware sleep, sense, and advertise loops with fail-safe defaults. +5. Build a small gateway-to-API path and operator list against that contract. +6. Bench radio, battery, and detection before any public range or month-long claim. + +## Suggested mstack packs + +Pack ids to consider later: `embedded-firmware` and `systems`. `mstack pack recommend` may cite repository evidence for them; it does not install packs. Adding a pack remains an explicit choice. diff --git a/examples/firmware-device/architecture.md b/examples/firmware-device/architecture.md new file mode 100644 index 0000000..2656853 --- /dev/null +++ b/examples/firmware-device/architecture.md @@ -0,0 +1,78 @@ +# Architecture: DockSense + +> Status: Accepted for hardware pilot +> Owner: Embedded systems lead +> Product source: [`product.md`](product.md) + +**Playbook lesson:** the firmware image is a bounded, fail-safe program. The +cloud side stays a modular monolith. Range, battery, and detection accuracy +are measurement gates, not architecture decorations. + +## Summary + +DockSense has three pieces in one product boundary: a small BLE firmware image on a battery-powered SoC, a pier or office gateway that observes advertisements, and an Express TypeScript API plus operator UI with PostgreSQL. The backend does not infer vacant from missing packets. Hardware-in-the-loop tests are required before any physical claim. + +```mermaid +flowchart LR + Puck[DockSense firmware] -->|BLE adv| Gateway + Gateway --> API[Express API] + API --> DB[(PostgreSQL)] + Dockmaster --> Web[Operator UI] + Web --> API + Tech[Attended flashing] --> Puck +``` + +## Structure and dependencies + +```text +firmware/ +├── sense/ # Occupancy sampling and debounce +├── radio/ # BLE advertisement builder +├── power/ # Sleep and brownout handling +└── failsafe/ # Unknown-on-fault defaults +apps/gateway/ # BLE observer and forwarder +apps/api/src/modules/ +├── devices/ # Identity, slip mapping, last-seen +├── occupancy/ # Server-side state and timeouts +└── ingest/ # Authenticated gateway reports +apps/web/ # Operator board +packages/contracts/ # BLE payload and HTTP schemas +``` + +Firmware modules do not include a network stack beyond BLE advertisements. The gateway is a translator: it does not decide occupancy. `occupancy` on the server applies the stale timeout. Repositories are the only API layer that import Prisma. The SoC SDK stays behind `firmware/radio` and `firmware/power`. + +## Data and state + +Firmware keeps no durable occupancy history. It samples, debounces, and advertises `occupied`, `vacant`, or `fault`. Brownout, sensor fault, and failed self-check advertise `fault`. + +Server entities: `Device`, `SlipMapping`, `OccupancyReport`, `OccupancyState` (`occupied`, `vacant`, `unknown`), `LastSeen`. Reports are append-only. The served state is `unknown` when `now - last authentic report` exceeds the configured timeout, regardless of the last occupied/vacant value. A `fault` advertisement also yields `unknown` for operators. + +Slip mapping is a backend record so a replaced puck does not keep a burned-in slip name as the only identity. Utilization metrics must not count `unknown` as `vacant`. + +## Protocol contract + +BLE advertisement (v1): device id, occupancy enum, firmware version, battery-voltage band (coarse, not a life claim), sequence number. Payload size fits a legacy advertisement. Unauthenticated observers may see occupancy; that is accepted for the pilot because slips are visible from the dock. The gateway authenticates to the API. + +`POST /v1/gateways/reports` — gateway identity, device id, observed payload, radio RSSI, received-at. Idempotent on `(gateway, device, sequence)`. Invalid payloads return `422`. + +`GET /v1/slips` — slip, served occupancy, last-seen, source (`report` or `timeout`). Never returns raw SoC vendor types. + +The firmware image budget and bootloader presence are recorded as constraints. Pilot flashing is attended and local; there is no unattended OTA in this architecture. + +## Safety and fail-safe defaults + +Power-on default before the first valid sample is `fault` (served as unknown), not `vacant`. Watchdog reset returns to the same default. Debounce lives in firmware so a person walking the finger pier does not toggle vacant; the debounce window is a named constant and a test, not a tuned-in-the-field secret. + +The API trusts gateway credentials, not BLE observers on the public pier. Device ids are not customer identifiers. Logs store device ids, slip ids, and enums, not RSSI traces by default. + +What this design does not claim: battery months, meters of range, IP rating, or detection precision. Those require named bench and dock trials. Architecture tests assert fail-safe state machines and protocol parsing, which is not a substitute for hardware evidence. + +## Deployment, testing, and operation + +Firmware builds in CI as a size-checked image and unit-tests sense/fail-safe on host. Hardware-in-the-loop runs on a bench jig before dock install: power loss, sensor cover, gateway down, duplicate sequence. The API and UI deploy as one backend with managed PostgreSQL. Gateways are provisioned devices, not cloud microservices. + +Alerts: slip unknown beyond expected, gateway silence, ingest authentication failures, and image-size regressions. If the API is down, firmware still advertises; the operator is told the board is stale, not that slips are vacant. + +## Evolution triggers + +Add OTA only with an A/B image and a recover-in-place procedure after a failed dock trial of attended updates. Add a second radio only if measured BLE coverage on the pier fails. Split gateway software only if fleet size or isolation requires it. Do not add cameras or identity to improve occupancy accuracy. Revisit debounce and timeout from dock evidence, not from dashboard aesthetics. diff --git a/examples/firmware-device/product.md b/examples/firmware-device/product.md new file mode 100644 index 0000000..37d7c48 --- /dev/null +++ b/examples/firmware-device/product.md @@ -0,0 +1,82 @@ +# Product: DockSense + +> Status: Approved for hardware pilot +> Owner: Harbor operations product lead +> Last updated: 2026-08-13 + +**Playbook lesson:** firmware products are constrained by physics and by what +you have measured. Fail-safe defaults and bench evidence belong in the +product definition; marketing range and battery claims do not. + +## Vision + +Marina staff can see which slips are occupied without walking the dock, without pretending the system knows more than the sensor has honestly reported. + +## Problem + +Harbor staff currently walk slips or rely on radio calls from boaters. Occupancy knowledge is stale at night and during weather. A vacant-looking slip that is occupied causes conflict; a dead sensor that looks vacant is worse. + +Observation from one marina manager interview and two staff shadowing sessions: evening walkthroughs miss transient occupancy, and staff already distrust "smart dock" vendors that advertised month-long batteries. Hypothesis: BLE pucks plus unknown-as-a-first-class state will be trusted if vacant is never inferred from silence. Whether staff will still walk the dock daily is an open question the pilot must measure. + +## Target users and personas + +**Primary user:** a marina dockmaster who assigns slips and needs a trustworthy occupancy board during operating hours. + +**Secondary users:** staff who walk the dock when the board says unknown, and a technician who replaces batteries and devices. + +**Dockmaster persona:** checks occupancy on a phone or office display, will not accept silent vacant, and succeeds when unknown triggers a walk rather than a booking. + +## User needs + +| Need | Evidence | +| --- | --- | +| Occupied vs vacant vs unknown are distinct | Staff distrust; high confidence | +| Silence or dead battery must not look vacant | Observed hazard; high confidence | +| Device is battery powered with no slip wiring | Site constraint; observation | +| Board usable from the office, not only on the pier | Manager request; medium confidence | +| Honest limits on range and battery | Prior vendor failure; high confidence | + +## First-release features + +| Feature | User outcome | Acceptance summary | +| --- | --- | --- | +| Three-state occupancy | Staff know what is actually known | Occupied, vacant, unknown; never a fourth implied state | +| BLE advertisement | Nearby gateway receives slip status | Documented payload; gateway absence yields unknown, not vacant | +| Last-seen and stale | Age of evidence is visible | Display shows time since last authentic report | +| Fail-safe timeout | Lost sensors cannot hide | After the declared timeout, status becomes unknown | +| Device identity | Technician can replace a puck | Slip mapping is a backend record, not a burned-in berth name only | +| Bench-gated claims | Sales and docs stay honest | No battery-life or range number ships without a named bench test | + +## Non-goals + +- Boat identification, cameras, GPS tracking, payment, reservations, mesh networking, customer-facing maps, or automatic enforcement. +- Over-the-air updates that can brick without a recover-in-place path. Pilot updates are local and attended. +- Claiming months of battery life, waterproof ratings, or pier-length range before environmental bench tests. + +## Success metrics + +| Metric | Hardware-pilot target | +| --- | --- | +| False vacant while a boat is in the slip | 0 in attended trials | +| Status unknown when the puck is powered off | 100% of timeout tests | +| Operator time vs full dock walk for the piloted slips | Measure; continue only if staff report less wasted walking | +| Firmware image size | Under the SoC budget recorded in architecture | +| Public battery or range claims | 0 until bench evidence is attached | + +Guardrails: occupancy is not identity; no camera; no precise tracking of people. Lost BLE is unknown. Accessibility target for the operator display is WCAG 2.2 AA. + +## Core journey + +1. A technician mounts a puck, maps its device id to slip A12, and confirms a gateway hears it. +2. A boat occupies the slip; the puck advertises occupied; the office board updates. +3. The boat leaves; the puck advertises vacant after the debounce window. +4. The battery dies; advertisements stop; the board shows unknown with last-seen, not vacant. +5. Staff walk A12 and replace the puck; mapping is updated. +6. No datasheet claims a runtime the bench has not measured. + +## Constraints and risks + +- Salt, cold, and RF on a pier are not lab conditions. Pilot evidence is labeled lab vs dock. +- Hypothesis: debounce will hide brief vacancies from people walking past; validate on-dock, not only on a bench. +- Regulatory: BLE transmit power and markings must follow the chosen SoC's certifications; the product does not invent RF claims. +- The backend is an internal operator tool. It must not present unknown as vacant to make utilization look high. diff --git a/examples/mobile-app/README.md b/examples/mobile-app/README.md new file mode 100644 index 0000000..56443ab --- /dev/null +++ b/examples/mobile-app/README.md @@ -0,0 +1,31 @@ +# Mobile Application Example: FieldNote + +FieldNote is a fictional offline-first app for municipal inspectors who capture site notes and evidence photos on iOS and Android, then sync when a network returns. + +I included a mobile example because permission prompts, store-review constraints, and offline queues are product decisions. They change what the inspector can finish in a basement, what the city may collect, and whether the first release can ship. Treating them as implementation details produces an app that looks complete on Wi-Fi and fails in the field. + +## What this example demonstrates + +- treat camera, location, and notification permissions as user-facing states, not OS footnotes; +- make offline capture and durable sync the core journey, not a later enhancement; +- use assigned sites as the location source of truth so precise tracking is unnecessary; +- keep one backend and shared domain rules rather than splitting "mobile APIs"; +- plan store privacy labels and background-mode limits before the first TestFlight or Play track. + +## Documents before implementation + +1. [`product.md`](product.md) defines one inspector cohort, permission fallbacks, and an offline-complete inspection. +2. [`architecture.md`](architecture.md) describes on-device state, the sync outbox, photo handling, and store-release constraints. + +## How I would deliver it + +1. Ride along with inspectors and capture where notes are lost today. +2. Define inspection, photo, permission, and sync state transitions, including denial and conflict. +3. Publish the backend contract and idempotent submit rules. +4. Implement on-device persistence and the outbox before polish UI. +5. Build the inspection journey against that contract on both platforms. +6. Pilot with a small crew, including no-signal sites, before a store submission. + +## Suggested mstack packs + +Pack ids to consider later: `mobile` and `qa-testing`. `mstack pack recommend` may cite repository evidence for them; it does not install packs. Adding a pack remains an explicit choice. diff --git a/examples/mobile-app/architecture.md b/examples/mobile-app/architecture.md new file mode 100644 index 0000000..7ada090 --- /dev/null +++ b/examples/mobile-app/architecture.md @@ -0,0 +1,78 @@ +# Architecture: FieldNote + +> Status: Accepted for field pilot +> Owner: Mobile application lead +> Product source: [`product.md`](product.md) + +**Playbook lesson:** the mobile clients are not a second product. Shared +inspection and sync rules live behind one API; platform adapters own +permissions, camera, and durable local storage. Store constraints are +architecture because they forbid whole classes of design. + +## Summary + +FieldNote is a modular monolith: React Native clients on iOS and Android, an Express TypeScript API, PostgreSQL through Prisma, and private object storage for evidence photos. Each device keeps an encrypted local store and an inspection outbox. One backend serves the pilot; there is no separate "mobile BFF" until a measured client-specific contract appears. + +```mermaid +flowchart LR + Inspector --> Apps[iOS and Android clients] + Apps --> Local[(On-device store + outbox)] + Apps --> API[Express API] + API --> DB[(PostgreSQL)] + API --> Auth[Supabase Auth] + API --> Photos[Private object storage] + Clerk --> Web[Records view] + Web --> API +``` + +## Structure and dependencies + +```text +apps/mobile/src/ +├── features/inspections/ # Draft UI and local complete +├── features/sync/ # Outbox drain, conflict display +└── platform/ # Permission, camera, filesystem adapters +apps/api/src/modules/ +├── assignments/ # Supervisor site lists +├── inspections/ # Server state and submit rules +├── media/ # Photo upload policy +└── identity/ # Inspector membership +packages/contracts/ # Runtime schemas shared by clients +packages/domain/ # Inspection and sync state transitions +``` + +Clients call module APIs after schema validation. Domain rules for complete, retry, and conflict live in `packages/domain`, not in screen components. Repositories are the only API layer that import Prisma. Platform permission APIs stay behind adapters so iOS and Android differences do not leak into inspection state. + +## Data and state + +On device: `AssignmentCache`, `InspectionDraft`, `PhotoBlob` (local file reference), `OutboxItem`, `SyncReceipt`. Drafts are unique per inspector and assignment. Complete is a local state; it does not require an HTTP round trip. + +On the server: `Inspector`, `Assignment`, `Inspection` (submitted snapshot), `Photo` (object key, scan state, stripped metadata), `SubmitIdempotency`. Inspection states are `accepted`, `rejected_conflict`, and `replaced_only_by_owner_before_lock`. The pilot locks a record after first successful submit. + +Photo EXIF is stripped on device when possible and again on upload completion. The product stores the assigned site identifier, not a location trail. An optional one-shot location sample may be attached as corroboration and is omitted when permission is denied. + +## API and sync contract + +The `/v1` API includes assignment download, idempotent inspection submit, signed photo upload start/complete, and sync acknowledgement. Submit uses an idempotency key created on the device at local complete. Conflicts return `409 inspection_locked` or `409 assignment_not_owned`; the client surfaces needs-attention rather than retrying forever. + +Responses never include another inspector's drafts or precise coordinates. Prisma models are not exposed. Photo bytes do not travel in the JSON body. + +The outbox drains in order per inspection, with bounded retries for transient network errors and a stop-and-alert path for validation or permission-to-upload failures. A crash mid-drain must leave the item retryable without duplicate photos. + +## Authentication, permissions, and privacy + +Supabase Auth identifies the inspector; the API loads current municipal membership on every mutation. Assignment download is scoped to that inspector. The records clerk role can read submitted inspections, not in-progress device drafts. + +Permission policy is product state: camera denied blocks only photo-required items; location denied is a no-op on site identity; notification denied still syncs in the foreground. The clients do not request background location or full photo-library access. + +Logs omit photo bytes, tokens, exact coordinates, and note bodies. Store privacy labels and Android data-safety disclosures must list camera, optional approximate location, and photos actually collected. + +## Deployment, testing, and operation + +The API and a small records UI deploy as one backend service with managed PostgreSQL and private object storage. Clients ship through TestFlight and an internal Play track before public store review. CI runs domain tests for sync and conflict, API contract tests, and device tests for process death, airplane mode, and permission denial. + +Operational signals: outbox age, submit error codes, photo processing failures, auth failures, and crash-free sessions. If the API is down, local complete remains available; the inspector sees pending sync. + +## Evolution triggers + +Add a native module only when a measured platform API cannot be expressed through the current adapter. Add background upload only if foreground drain misses the 24-hour sync target and store review still allows the narrower mode. Split a mobile-specific gateway only when payload or auth requirements diverge enough to justify a second contract. Do not claim battery, crash, or offline durability targets beyond what the instrumented pilot measured. diff --git a/examples/mobile-app/product.md b/examples/mobile-app/product.md new file mode 100644 index 0000000..6e772a8 --- /dev/null +++ b/examples/mobile-app/product.md @@ -0,0 +1,81 @@ +# Product: FieldNote + +> Status: Approved for field pilot +> Owner: City inspections product lead +> Last updated: 2026-08-13 + +**Playbook lesson:** platform permissions, offline queues, and store-release +rules are product scope. An inspector who cannot save without signal, or who +is blocked by a camera prompt, does not have a working inspection tool. + +## Vision + +Municipal inspectors finish an accurate site record in the field, even without a network, without handing the city more location data than the assignment already provides. + +## Problem + +Inspectors currently jot notes in paper packets or a generic notes app, take photos in the camera roll, and retype a report at the office. Basements, rural parcels, and parking structures regularly have no useful signal. Lost drafts and mismatched photos cause revisits and disputed findings. + +Nine inspector interviews (observation) found that seven had lost at least one in-progress record in the past month to connectivity, an OS kill, or a crash. Office staff (observation) spend a same-day hour merging photos to addresses. Whether a dedicated app will be used if paper remains allowed is still a hypothesis. + +## Target users and personas + +**Primary user:** a municipal property or safety inspector who completes 6–12 assigned site visits per shift on a city-issued phone. + +**Secondary users:** a supervisor who assigns today's sites, and a records clerk who needs a complete submitted inspection. + +**Inspector persona:** works in variable signal, needs to capture notes and evidence photos in under a few minutes per site, will deny unused permissions, and succeeds when an inspection can be marked complete locally and is known to have synced later. + +## User needs + +| Need | Evidence | +| --- | --- | +| Save notes and photos with no network | Observed; high confidence | +| Survive app kills and battery swaps mid-visit | Observed; high confidence | +| Know assigned sites without hunting an address | Observed; medium confidence | +| Continue if camera or location is denied | Hypothesis; high importance | +| Avoid citywide tracking of the inspector | Inspector concern; treat as a constraint | + +## First-release features + +| Feature | User outcome | Acceptance summary | +| --- | --- | --- | +| Assigned site list | Inspector knows today's work | Downloaded list usable offline after last successful sync | +| Offline inspection draft | Work is not lost underground | Notes save locally; OS kill restores the same draft | +| Evidence photos | Finding is documented | Camera capture attaches to the inspection; library-wide access is not required | +| Local complete | Shift can finish without signal | Complete is valid on device; sync is a separate durable queue | +| Sync status | Inspector trusts submission | Per-inspection states: pending, syncing, synced, failed, needs attention | +| Permission fallbacks | Denial does not brick the visit | Location optional; photo-required items stay incomplete until a photo exists | + +## Non-goals + +- Live supervisor tracking, background location, citizen-facing reporting, automatic code-violation decisions, wearables, offline maps as a navigation product, or multi-city tenancy. +- Editing another inspector's submitted record in the pilot. + +## Success metrics + +| Metric | Six-week pilot target | +| --- | --- | +| Inspections completed in the field without a same-visit office retype | At least 80% of pilot visits | +| Local drafts lost after process death or overnight | 0 in the instrumented cohort | +| Sync eventual success within 24 hours of regained network | At least 99% of completed inspections | +| Visits blocked solely by a permission dialog | Under 5% | +| Inspector-reported extra time vs paper | Median not worse than current after week two | + +Guardrails: no continuous location trail stored or transmitted; photos are inspection-scoped; store privacy labels match actual collection; accessibility target is the platform equivalent of WCAG 2.2 AA for inspector-facing flows. + +## Core journey + +1. The inspector syncs assignments on city Wi-Fi before leaving. +2. At a basement site they open the assigned record, write findings, and take two camera photos with no signal. +3. They deny location; the assigned parcel remains the site identity. +4. They mark the inspection complete; it stays queued on device. +5. On return to coverage the outbox submits; the inspector sees synced. +6. The clerk opens a complete record with photos attached to the correct site. + +## Constraints and risks + +- iOS and Android permission copy, photo-picker rules, and privacy nutrition labels are release constraints, not QA trivia. +- Background modes that look like tracking will fail both staff trust and store review; the pilot uses foreground capture only. +- Hypothesis: one inspector owns a draft until submit; concurrent edits are out of scope and must be rejected, not merged silently. +- Devices are city-issued; still treat the filesystem and photos as sensitive municipal records with retention aligned to existing inspection policy. diff --git a/packages/cli/README.md b/packages/cli/README.md index 5484a39..e619882 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -10,7 +10,7 @@ mstack init mstack ai setup ``` -`mstack init` installs the planning documents, project configuration, and ownership manifest. `mstack ai setup` then installs the capability-aware repository instructions, specialist agents, reusable skills and prompts, advisory hooks, and reference templates for selected coding environments. Both preview changes, preserve existing work, and record generated-file ownership. +`mstack init` installs the planning documents, project configuration, and ownership manifest. `mstack ai setup` then installs the capability-aware repository instructions, specialist agents, reusable skills and prompts, advisory hooks, and reference templates for selected coding environments. Curated domain packs add extra specialists and argv-only task recipes after an explicit `mstack pack add`. Both preview changes, preserve existing work, and record generated-file ownership. ## Why mstack exists @@ -145,6 +145,7 @@ The runtime catalog is discoverable without inspecting generated directories: ```sh mstack catalog mstack catalog agents +mstack catalog --query mobile mstack catalog hooks --json ``` @@ -236,7 +237,8 @@ These totals come from the runtime catalog, adapter registry, templates, and exa | Automation hooks | 4 | | Prompt packs | 19 | | Runtime templates | 10 | -| Worked example projects | 5 | +| Curated capability packs | 14 | +| Worked example projects | 9 | | Supported package managers | 4 | Catalog totals are enforced by [runtime tests](https://github.com/IMisbahk/mstack/blob/main/packages/ai-integrations/test/runtime.test.ts). @@ -416,7 +418,14 @@ Every command below is implemented. `mstack ai`, `mstack config`, and `mstack pl | `explain` | Walk through the installed workflow | `mstack explain [--json]`
`mstack explain` | Describes the planning documents that exist and points to the first incomplete step. Makes no changes. | | `ai setup` | Install the AI engineering pack | `mstack ai setup [runtimes...] [options]`
`mstack ai setup claude-code codex --dry-run` | Detects or accepts runtimes, renders supported capabilities, reports limitations and conflicts, confirms before applying, and updates the manifest. Supports `--all`, `--yes`, `--force`, and `--json`. | | `ai list` | Inspect runtime support and detection | `mstack ai list [--json]`
`mstack ai list` | Shows every environment as available, detected, or configured with native and adapted capability counts. Makes no changes. | -| `catalog` | Discover runtime resources | `mstack catalog [kind] [--json]`
`mstack catalog agents` | Lists source-backed agents, skills, prompts, hooks, and templates with exact catalog totals. Makes no changes. | +| `catalog` | Discover runtime and pack resources | `mstack catalog [kind] [--query ] [--json]`
`mstack catalog --query mobile` | Lists source-backed packs, agents, skills, prompts, hooks, templates, and task recipes. Makes no changes. | +| `pack list` | List curated packs | `mstack pack list [--json]`
`mstack pack list` | Shows bundled pack ids, versions, and descriptions. Makes no changes. | +| `pack info` | Inspect one pack | `mstack pack info [--json]`
`mstack pack info mobile` | Shows specialists, skills, prompts, tasks, and toolchains. Makes no changes. | +| `pack recommend` | Suggest packs from repository evidence | `mstack pack recommend [--json]`
`mstack pack recommend` | Cites well-known marker files and package dependencies. Does not install packs. | +| `pack add` | Select packs and reconcile runtimes | `mstack pack add [options]`
`mstack pack add mobile qa-testing --dry-run` | Composes selected packs into existing AI setup. Requires `--yes` in non-interactive privileged setup. | +| `task list` | List argv-only recipes | `mstack task list [--pack ] [--risk ] [--json]`
`mstack task list --risk read-only` | Lists policy-gated recipes. Makes no changes. | +| `task run` | Run one recipe | `mstack task run [--dry-run] [--yes]`
`mstack task run repository.status --dry-run` | Executes literal argv with `shell: false`, file preconditions, and the configured task policy. | +| `agent` | Describe installed specialists | `mstack agent [id] [--json]`
`mstack agent` | Lists specialists from the core pack and selected capability packs. Does not execute models. | | `validate` | Verify repository and runtime integrity | `mstack validate [directory] [--strict] [--json]`
`mstack validate --strict` | Checks planning readiness, repository ownership, and AI runtime drift. Exits with code `4` on errors, or on warnings in strict mode. Makes no changes. | | `plugins list` | Inspect capability plugins | `mstack plugins list [--json]`
`mstack plugins list --json` | Lists plugin metadata and integration, template, and generator contributions. Makes no changes. | | `config list` | Print resolved configuration | `mstack config list [--json]`
`mstack config list` | Merges user and project preferences, then prints the resolved values. Makes no changes. | @@ -434,7 +443,6 @@ The following command families describe the platform direction. **They are plann | Planned command | Direction | | --- | --- | -| `mstack agent` | Invoke and coordinate installed specialists | | `mstack prompt` | Apply and compose prompt packs | | `mstack hook` | Manage activation of approved hooks | | `mstack skill` | Install or update individual engineering skills | @@ -453,7 +461,7 @@ Today, use `mstack ai setup` for runtime installation and `mstack update` for th │ ├── cli/ CLI guide, command reference, runtime matrix, migrations │ ├── decisions/ Architecture decision records │ └── features/ Capability specifications -├── examples/ Five worked product and architecture examples +├── examples/ Nine worked product and architecture examples ├── packages/ │ ├── ai-integrations/ Runtime catalog, adapters, ownership, verification │ └── cli/ Commands, configuration, manifests, project services diff --git a/packages/cli/src/commands/ai.ts b/packages/cli/src/commands/ai.ts index a3f00c2..f61f817 100644 --- a/packages/cli/src/commands/ai.ts +++ b/packages/cli/src/commands/ai.ts @@ -21,13 +21,23 @@ import { readManifest } from "../services/manifest.js"; import { detectRuntimes } from "../services/runtimes.js"; import { createDefaultPackRegistry } from "../packs/index.js"; +function mergeById(groups: readonly (readonly T[] | undefined)[]): T[] { + const merged = new Map(); + for (const group of groups) { + for (const item of group ?? []) { + if (!merged.has(item.id)) merged.set(item.id, item); + } + } + return [...merged.values()]; +} + function mergeSpecs(base: IntegrationSpec, additions: readonly Pick[]): IntegrationSpec { return { ...base, - agents: [...(base.agents ?? []), ...additions.flatMap((item) => item.agents ?? [])], - skills: [...(base.skills ?? []), ...additions.flatMap((item) => item.skills ?? [])], - prompts: [...(base.prompts ?? []), ...additions.flatMap((item) => item.prompts ?? [])], - templates: [...(base.templates ?? []), ...additions.flatMap((item) => item.templates ?? [])], + agents: mergeById([base.agents, ...additions.map((item) => item.agents)]), + skills: mergeById([base.skills, ...additions.map((item) => item.skills)]), + prompts: mergeById([base.prompts, ...additions.map((item) => item.prompts)]), + templates: mergeById([base.templates, ...additions.map((item) => item.templates)]), }; } diff --git a/packages/cli/src/commands/catalog.ts b/packages/cli/src/commands/catalog.ts index c83bd7e..92a335e 100644 --- a/packages/cli/src/commands/catalog.ts +++ b/packages/cli/src/commands/catalog.ts @@ -22,20 +22,38 @@ export interface CatalogReport { readonly schemaVersion: 1; readonly counts: Readonly>; readonly items: readonly CatalogItem[]; + readonly query?: string; } -export function buildCatalog(kind?: CatalogKind): CatalogReport { +function matchesQuery(item: CatalogItem, query: string | undefined): boolean { + if (query === undefined || query.trim() === "") return true; + const needle = query.trim().toLowerCase(); + return item.id.toLowerCase().includes(needle) + || item.description.toLowerCase().includes(needle) + || (item.detail?.toLowerCase().includes(needle) ?? false); +} + +export function buildCatalog(kind?: CatalogKind, query?: string): CatalogReport { const packs = createDefaultPackRegistry().list(); + const packSpecs = packs.map((pack) => ({ pack, spec: pack.createSpec({ projectName: "Project" }) })); const items: CatalogItem[] = [ ...packs.map((item) => ({ kind: "packs" as const, id: item.id, description: item.description, detail: item.version })), - ...engineeringAgents.map((item) => ({ kind: "agents" as const, id: item.id, description: item.description })), - ...engineeringSkills.map((item) => ({ kind: "skills" as const, id: item.id, description: item.description })), + ...engineeringAgents.map((item) => ({ kind: "agents" as const, id: item.id, description: item.description, detail: "build-like-this" })), + ...packSpecs.flatMap(({ pack, spec }) => (spec.agents ?? []).map((item) => ({ kind: "agents" as const, id: item.id, description: item.description, detail: pack.id }))), + ...engineeringSkills.map((item) => ({ kind: "skills" as const, id: item.id, description: item.description, detail: "build-like-this" })), + ...packSpecs.flatMap(({ pack, spec }) => (spec.skills ?? []).map((item) => ({ kind: "skills" as const, id: item.id, description: item.description, detail: pack.id }))), ...engineeringPrompts.map((item) => ({ kind: "prompts" as const, id: item.id, description: item.description, - ...(item.argumentHint === undefined ? {} : { detail: item.argumentHint }), + detail: item.argumentHint === undefined ? "build-like-this" : `${item.argumentHint} · build-like-this`, })), + ...packSpecs.flatMap(({ pack, spec }) => (spec.prompts ?? []).map((item) => ({ + kind: "prompts" as const, + id: item.id, + description: item.description, + detail: pack.id, + }))), ...engineeringHooks.map((item) => ({ kind: "hooks" as const, id: item.id, @@ -47,23 +65,29 @@ export function buildCatalog(kind?: CatalogKind): CatalogReport { id: item.id ?? item.path, description: `Reusable engineering template at ${item.path}.`, })), - ...packs.flatMap((pack) => pack.tasks.map((item) => ({ kind: "task-recipes" as const, id: item.id, description: item.description, detail: item.risk }))), + ...packs.flatMap((pack) => pack.tasks.map((item) => ({ kind: "task-recipes" as const, id: item.id, description: item.description, detail: `${item.risk} · ${pack.id}` }))), ]; + const filtered = items.filter((item) => (kind === undefined || item.kind === kind) && matchesQuery(item, query)); const counts = Object.fromEntries(CATALOG_KINDS.map((name) => [name, items.filter((item) => item.kind === name).length])) as Record; - return { schemaVersion: 1, counts, items: kind === undefined ? items : items.filter((item) => item.kind === kind) }; + return { + schemaVersion: 1, + counts, + items: filtered, + ...(query === undefined || query.trim() === "" ? {} : { query: query.trim() }), + }; } -export function catalogCommand(output: Output, kind: CatalogKind | undefined, json: boolean): void { - const report = buildCatalog(kind); +export function catalogCommand(output: Output, kind: CatalogKind | undefined, json: boolean, query?: string): void { + const report = buildCatalog(kind, query); if (json) return output.json(report); output.title(kind === undefined ? "mstack catalog" : `mstack catalog · ${kind}`); for (const group of CATALOG_KINDS) { - const items = report.items.filter((item) => item.kind === group); - if (items.length === 0) continue; - output.line(`\n${title(group)} (${items.length})`); - const width = Math.max(13, ...items.map((item) => item.id.length)); - for (const item of items) output.field(item.id, item.description, width); + const groupItems = report.items.filter((item) => item.kind === group); + if (groupItems.length === 0) continue; + output.line(`\n${title(group)} (${groupItems.length})`); + const width = Math.max(13, ...groupItems.map((item) => item.id.length)); + for (const item of groupItems) output.field(item.id, item.description, width); } output.next(`Install or reconcile core resources with ${output.command("mstack ai setup")} and curated packs with ${output.command("mstack pack add ")}`); } diff --git a/packages/cli/src/commands/packs.ts b/packages/cli/src/commands/packs.ts index 26eb16a..bb73743 100644 --- a/packages/cli/src/commands/packs.ts +++ b/packages/cli/src/commands/packs.ts @@ -1,35 +1,111 @@ import type { Output } from "../core/output.js"; import { CliError } from "../core/errors.js"; import { createDefaultPackRegistry } from "../packs/index.js"; +import { recommendPacks } from "../packs/recommend.js"; import { readManifest, updateManifest } from "../services/manifest.js"; import { aiSetupCommand } from "./ai.js"; import { inspectRepository } from "../services/health.js"; export function packListCommand(output: Output, json: boolean): void { - const packs = createDefaultPackRegistry().list().map((pack) => ({ id: pack.id, version: pack.version, displayName: pack.displayName, description: pack.description, dependencies: pack.dependencies ?? [], requiredToolchains: pack.requiredToolchains ?? [], tasks: pack.tasks.map((task) => task.id) })); + const packs = createDefaultPackRegistry().list().map((pack) => ({ + id: pack.id, + version: pack.version, + displayName: pack.displayName, + description: pack.description, + dependencies: pack.dependencies ?? [], + requiredToolchains: pack.requiredToolchains ?? [], + tasks: pack.tasks.map((task) => task.id), + })); if (json) return output.json({ schemaVersion: 1, packs }); - output.title("mstack capability packs"); for (const pack of packs) output.field(`${pack.id} ${pack.version}`, pack.description); + output.title("mstack capability packs"); + for (const pack of packs) output.field(`${pack.id} ${pack.version}`, pack.description); } + export function packInfoCommand(output: Output, id: string, json: boolean): void { const pack = createDefaultPackRegistry().get(id); - if (json) return output.json({ schemaVersion: 1, pack }); - output.title(`${pack.displayName} ${pack.version}`); output.field("ID", pack.id); output.field("Description", pack.description); output.field("Dependencies", (pack.dependencies ?? []).join(", ") || "none"); output.field("Toolchains", (pack.requiredToolchains ?? []).join(", ") || "none"); output.field("Tasks", pack.tasks.map((item) => item.id).join(", ") || "none"); + const spec = pack.createSpec({ projectName: "Project" }); + const info = { + id: pack.id, + version: pack.version, + displayName: pack.displayName, + description: pack.description, + dependencies: pack.dependencies ?? [], + requiredToolchains: pack.requiredToolchains ?? [], + agents: (spec.agents ?? []).map((agent) => agent.id), + skills: (spec.skills ?? []).map((skill) => skill.id), + prompts: (spec.prompts ?? []).map((prompt) => prompt.id), + tasks: pack.tasks.map((task) => ({ id: task.id, risk: task.risk, description: task.description })), + }; + if (json) { + return output.json({ + schemaVersion: 1, + pack: { + id: pack.id, + version: pack.version, + displayName: pack.displayName, + description: pack.description, + dependencies: info.dependencies, + requiredToolchains: info.requiredToolchains, + risks: pack.risks ?? [], + agents: info.agents, + skills: info.skills, + prompts: info.prompts, + tasks: pack.tasks, + }, + }); + } + output.title(`${pack.displayName} ${pack.version}`); + output.field("ID", pack.id); + output.field("Description", pack.description); + output.field("Dependencies", info.dependencies.join(", ") || "none"); + output.field("Toolchains", info.requiredToolchains.join(", ") || "none"); + output.field("Agents", info.agents.join(", ") || "none"); + output.field("Skills", info.skills.join(", ") || "none"); + output.field("Prompts", info.prompts.join(", ") || "none"); + output.field("Tasks", info.tasks.map((task) => task.id).join(", ") || "none"); +} + +export async function packRecommendCommand(cwd: string, output: Output, json: boolean): Promise { + const health = await inspectRepository(cwd); + const report = await recommendPacks(health.root); + if (json) return output.json(report); + output.title("mstack pack recommend"); + if (report.recommendations.length === 0) { + output.line(" No pack evidence was found in well-known repository markers."); + } + for (const item of report.recommendations) { + const state = item.alreadySelected ? "selected" : "suggested"; + output.field(`${item.id} ${item.confidence}`, `${state} · ${item.reasons[0]}`); + for (const reason of item.reasons.slice(1)) output.line(` ${reason}`); + } + output.next(`Review a pack with ${output.command("mstack pack info ")} and install only after that review with ${output.command("mstack pack add ")}`); } + export async function packAddCommand(options: { cwd: string; ids: readonly string[]; runtimes: readonly string[]; yes: boolean; dryRun: boolean; json: boolean; output: Output }): Promise { - const health = await inspectRepository(options.cwd); if (!health.initialized) throw new CliError("Build Like This is not initialized in this repository.", { exitCode: 2, hints: ["Run mstack init first."] }); - const registry = createDefaultPackRegistry(); const current = (await readManifest(health.root))?.packs?.map((item) => item.id) ?? []; + const health = await inspectRepository(options.cwd); + if (!health.initialized) throw new CliError("Build Like This is not initialized in this repository.", { exitCode: 2, hints: ["Run mstack init first."] }); + const registry = createDefaultPackRegistry(); + const current = (await readManifest(health.root))?.packs?.map((item) => item.id) ?? []; const selected = registry.resolve([...current, ...options.ids]); await aiSetupCommand({ cwd: health.root, runtimes: options.runtimes, all: false, yes: options.yes, dryRun: options.dryRun, force: false, json: options.json, output: options.output, packs: selected.map((item) => item.id) }); } + export async function packRemoveCommand(options: { cwd: string; ids: readonly string[]; yes: boolean; dryRun: boolean; json: boolean; output: Output }): Promise { - const health = await inspectRepository(options.cwd); if (!health.initialized) throw new CliError("Build Like This is not initialized in this repository.", { exitCode: 2 }); - const manifest = await readManifest(health.root); const present = manifest?.packs ?? []; const remove = new Set(options.ids); + const health = await inspectRepository(options.cwd); + if (!health.initialized) throw new CliError("Build Like This is not initialized in this repository.", { exitCode: 2 }); + const manifest = await readManifest(health.root); + const present = manifest?.packs ?? []; + const remove = new Set(options.ids); const remaining = present.filter((pack) => !remove.has(pack.id)); if ((manifest?.integrations.length ?? 0) === 0) { - if (options.dryRun) { if (options.json) return options.output.json({ schemaVersion: 1, mode: "dry-run", remove: options.ids, remaining }); return; } + if (options.dryRun) { + if (options.json) return options.output.json({ schemaVersion: 1, mode: "dry-run", remove: options.ids, remaining }); + return; + } await updateManifest(health.root, { files: [], packs: remaining }); if (options.json) return options.output.json({ schemaVersion: 1, mode: "applied", remove: options.ids, remaining }); - options.output.success(`Removed ${options.ids.join(", ")} from the selected pack manifest.`); return; + options.output.success(`Removed ${options.ids.join(", ")} from the selected pack manifest.`); + return; } await aiSetupCommand({ cwd: health.root, runtimes: manifest!.integrations, all: false, yes: options.yes, dryRun: options.dryRun, force: false, json: options.json, output: options.output, packs: remaining.map((pack) => pack.id) }); } diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 844e81e..de75f81 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -15,6 +15,7 @@ export async function statusCommand(cwd: string, output: Output, json: boolean): output.field(label, `${detail} · ${document.path}`); } output.field("AI runtimes", report.integrations.length > 0 ? report.integrations.join(", ") : "not configured"); + output.field("Packs", report.packs.length > 0 ? report.packs.join(", ") : "none selected"); output.field("Manifest", report.manifest ?? "not found"); const destination = report.next.path ? ` ${report.next.path}` : report.next.command ? ` ${output.command(report.next.command)}` : ""; output.next(`${report.next.message}${destination}`); diff --git a/packages/cli/src/commands/tasks.ts b/packages/cli/src/commands/tasks.ts index f26feec..77072a9 100644 --- a/packages/cli/src/commands/tasks.ts +++ b/packages/cli/src/commands/tasks.ts @@ -3,30 +3,157 @@ import path from "node:path"; import * as prompts from "@clack/prompts"; import type { Output } from "../core/output.js"; import { CliError } from "../core/errors.js"; +import { pathExists } from "../core/fs.js"; import { createDefaultPackRegistry } from "../packs/index.js"; -import type { TaskRisk } from "../packs/types.js"; +import type { TaskListFilter, TaskRecipe, TaskRisk } from "../packs/types.js"; import { ConfigStore } from "../services/config.js"; -export function taskListCommand(output: Output, json: boolean): void { const tasks = createDefaultPackRegistry().list().flatMap((pack) => pack.tasks.map((task) => ({ ...task, pack: pack.id }))); if (json) return output.json({ schemaVersion: 1, tasks }); output.title("mstack task recipes"); for (const task of tasks) output.field(task.id, `${task.risk} · ${task.description}`); } -export function taskShowCommand(output: Output, id: string, json: boolean): void { const task = createDefaultPackRegistry().task(id); if (json) return output.json({ schemaVersion: 1, task }); output.title(task.id); output.field("Risk", task.risk); output.field("Steps", task.steps.map((step) => step.argv.join(" ")).join("; ")); output.field("Description", task.description); } -function needsConfirmation(policy: string, risk: TaskRisk): boolean { return policy === "strict" || (policy === "balanced" && risk !== "read-only"); } -export async function taskRunCommand(options: { cwd: string; id: string; yes: boolean; dryRun: boolean; json: boolean; inputs?: readonly string[]; output: Output }): Promise { - const task = createDefaultPackRegistry().task(options.id); const policy = (await new ConfigStore({ cwd: options.cwd }).resolved()).taskPolicy ?? "balanced"; - const inputs = new Map((options.inputs ?? []).map((raw) => { const index = raw.indexOf("="); if (index < 1) throw new CliError("Task inputs must use name=value.", { exitCode: 2 }); return [raw.slice(0, index), raw.slice(index + 1)]; })); - for (const input of task.inputs ?? []) if (input.required && !inputs.get(input.name)) throw new CliError(`Task ${task.id} requires --input ${input.name}=...`, { exitCode: 2 }); - const steps = task.steps.map((step) => ({ ...step, argv: step.argv.map((part) => part.replace(/{{([a-zA-Z][a-zA-Z0-9_-]*)}}/g, (_match, name: string) => { const value = inputs.get(name); if (value === undefined) throw new CliError(`Missing task input: ${name}`, { exitCode: 2 }); return value; })) })); +function isInside(root: string, target: string): boolean { + const relative = path.relative(root, target); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +export function taskListCommand(output: Output, json: boolean, filter: TaskListFilter = {}): void { + const tasks = createDefaultPackRegistry().tasks(filter).map((item) => ({ + ...item.task, + pack: item.pack, + })); + if (json) return output.json({ schemaVersion: 1, tasks, ...(filter.pack === undefined ? {} : { pack: filter.pack }), ...(filter.risk === undefined ? {} : { risk: filter.risk }) }); + output.title("mstack task recipes"); + if (tasks.length === 0) output.line(" No task recipes matched the filter."); + for (const task of tasks) output.field(task.id, `${task.pack} · ${task.risk} · ${task.description}`); +} + +export function taskShowCommand(output: Output, id: string, json: boolean): void { + const registry = createDefaultPackRegistry(); + const task = registry.task(id); + const pack = registry.taskPack(id); + if (json) return output.json({ schemaVersion: 1, pack, task }); + output.title(task.id); + output.field("Pack", pack); + output.field("Risk", task.risk); + output.field("Steps", task.steps.map((step) => step.argv.join(" ")).join("; ")); + output.field("Preconditions", (task.preconditions ?? []).join(", ") || "none"); + output.field("Description", task.description); +} + +function needsConfirmation(policy: string, risk: TaskRisk): boolean { + return policy === "strict" || (policy === "balanced" && risk !== "read-only"); +} + +async function assertPreconditions(cwd: string, task: TaskRecipe): Promise { + const root = path.resolve(cwd); + for (const relativePath of task.preconditions ?? []) { + if (relativePath.startsWith("/") || relativePath.includes("\\") || relativePath.split("/").includes("..")) { + throw new CliError(`Task ${task.id} has an unsafe precondition path.`, { exitCode: 2 }); + } + const target = path.resolve(root, relativePath); + if (!isInside(root, target)) { + throw new CliError(`Task ${task.id} precondition escaped the repository: ${relativePath}`, { exitCode: 2 }); + } + if (!(await pathExists(target))) { + throw new CliError(`Task ${task.id} is missing precondition ${relativePath}.`, { + exitCode: 2, + hints: [`Create or restore ${relativePath}, or choose a different recipe with mstack task list.`], + }); + } + } +} + +export async function taskRunCommand(options: { + cwd: string; + id: string; + yes: boolean; + dryRun: boolean; + json: boolean; + inputs?: readonly string[]; + output: Output; +}): Promise { + const task = createDefaultPackRegistry().task(options.id); + const policy = (await new ConfigStore({ cwd: options.cwd }).resolved()).taskPolicy ?? "balanced"; + const inputs = new Map((options.inputs ?? []).map((raw) => { + const index = raw.indexOf("="); + if (index < 1) throw new CliError("Task inputs must use name=value.", { exitCode: 2 }); + return [raw.slice(0, index), raw.slice(index + 1)]; + })); + for (const input of task.inputs ?? []) { + if (input.required && !inputs.get(input.name)) throw new CliError(`Task ${task.id} requires --input ${input.name}=...`, { exitCode: 2 }); + } + const steps = task.steps.map((step) => ({ + ...step, + argv: step.argv.map((part) => part.replace(/{{([a-zA-Z][a-zA-Z0-9_-]*)}}/g, (_match, name: string) => { + const value = inputs.get(name); + if (value === undefined) throw new CliError(`Missing task input: ${name}`, { exitCode: 2 }); + return value; + })), + })); const confirmation = needsConfirmation(policy, task.risk); - const resultBase = { schemaVersion: 1, task: task.id, policy, risk: task.risk, steps: steps.map((step) => ({ argv: step.argv })) }; - if (options.dryRun) { if (options.json) return options.output.json({ ...resultBase, mode: "dry-run" }); options.output.title(`mstack task run · ${task.id}`); steps.forEach((step) => options.output.line(step.argv.join(" "))); return; } + const resultBase = { + schemaVersion: 1, + task: task.id, + policy, + risk: task.risk, + preconditions: task.preconditions ?? [], + steps: steps.map((step) => ({ argv: step.argv })), + }; + if (options.dryRun) { + if (options.json) return options.output.json({ ...resultBase, mode: "dry-run" }); + options.output.title(`mstack task run · ${task.id}`); + steps.forEach((step) => options.output.line(step.argv.join(" "))); + return; + } + await assertPreconditions(options.cwd, task); if (policy === "automation" && task.risk !== "read-only" && !options.yes) { throw new CliError("Automation policy still requires --yes for a non-read-only task.", { exitCode: 3, hints: ["Review with --dry-run, then pass --yes."] }); } if (confirmation && !options.yes) { - if (!process.stdin.isTTY || !process.stdout.isTTY || options.json) throw new CliError("Task execution requires explicit approval.", { exitCode: 3, hints: ["Review with --dry-run, then pass --yes."] }); - const approved = await prompts.confirm({ message: `Run ${task.id} (${task.risk})?`, initialValue: false }); if (prompts.isCancel(approved) || !approved) throw new CliError("Task execution cancelled.", { exitCode: 130 }); + if (!process.stdin.isTTY || !process.stdout.isTTY || options.json) { + throw new CliError("Task execution requires explicit approval.", { exitCode: 3, hints: ["Review with --dry-run, then pass --yes."] }); + } + const approved = await prompts.confirm({ message: `Run ${task.id} (${task.risk})?`, initialValue: false }); + if (prompts.isCancel(approved) || !approved) throw new CliError("Task execution cancelled.", { exitCode: 130 }); } const records: { argv: readonly string[]; exitCode: number; startedAt: string; finishedAt: string }[] = []; - for (const step of steps) { const startedAt = new Date().toISOString(); const exitCode = await execute(step.argv, path.resolve(options.cwd, task.cwd ?? "."), task.timeoutMs); const finishedAt = new Date().toISOString(); records.push({ argv: step.argv, exitCode, startedAt, finishedAt }); if (exitCode !== 0) { if (options.json) return options.output.json({ ...resultBase, mode: "failed", failedStep: records.length, records }); throw new CliError(`Task ${task.id} failed at step ${records.length}: ${step.argv.join(" ")}`, { exitCode }); } } - if (options.json) return options.output.json({ ...resultBase, mode: "completed", records }); options.output.success(`Completed ${task.id}`); + for (const step of steps) { + const startedAt = new Date().toISOString(); + const exitCode = await execute(step.argv, path.resolve(options.cwd, task.cwd ?? "."), task.timeoutMs); + const finishedAt = new Date().toISOString(); + records.push({ argv: step.argv, exitCode, startedAt, finishedAt }); + if (exitCode !== 0) { + const missing = exitCode === 127; + if (options.json) return options.output.json({ ...resultBase, mode: "failed", failedStep: records.length, records }); + throw new CliError( + missing + ? `Task ${task.id} could not find executable ${step.argv[0]}.` + : `Task ${task.id} failed at step ${records.length}: ${step.argv.join(" ")}`, + { + exitCode, + hints: missing + ? [`Install ${step.argv[0]} or choose a recipe that matches this repository.`] + : step.recovery === undefined ? [] : [step.recovery], + }, + ); + } + } + if (options.json) return options.output.json({ ...resultBase, mode: "completed", records }); + options.output.success(`Completed ${task.id}`); +} + +function execute(argv: readonly string[], cwd: string, timeoutMs = 120_000): Promise { + if (argv.length === 0) return Promise.resolve(1); + return new Promise((resolve, reject) => { + const child = spawn(argv[0]!, argv.slice(1), { cwd, stdio: "inherit", shell: false }); + const timer = setTimeout(() => { + child.kill("SIGTERM"); + }, timeoutMs); + child.once("error", (error: NodeJS.ErrnoException) => { + clearTimeout(timer); + if (error.code === "ENOENT") return resolve(127); + reject(error); + }); + child.once("close", (code) => { + clearTimeout(timer); + resolve(code ?? 1); + }); + }); } -function execute(argv: readonly string[], cwd: string, timeoutMs = 120_000): Promise { if (argv.length === 0) return Promise.resolve(1); return new Promise((resolve, reject) => { const child = spawn(argv[0]!, argv.slice(1), { cwd, stdio: "inherit", shell: false }); const timer = setTimeout(() => { child.kill("SIGTERM"); }, timeoutMs); child.once("error", reject); child.once("close", (code) => { clearTimeout(timer); resolve(code ?? 1); }); }); } diff --git a/packages/cli/src/packs/builtins.ts b/packages/cli/src/packs/builtins.ts index 4860cec..fd80a9b 100644 --- a/packages/cli/src/packs/builtins.ts +++ b/packages/cli/src/packs/builtins.ts @@ -1,50 +1,425 @@ -import type { AgentDefinition, SkillDefinition } from "../../../ai-integrations/src/index.js"; +import { createCapabilityPack, createPackPrompt, createPackSkill, createSpecialist, createTaskRecipe } from "./define.js"; import type { CapabilityPack, TaskRecipe } from "./types.js"; -const task = (id: string, description: string, risk: TaskRecipe["risk"], argv: readonly string[]): TaskRecipe => ({ id, description, risk, steps: [{ argv }] }); -const coreTasks: readonly TaskRecipe[] = [ - task("repository.status", "Show working-tree status.", "read-only", ["git", "status", "--short"]), - task("repository.diff", "Show the current diff.", "read-only", ["git", "diff", "--"]), - task("repository.log", "Show recent commits.", "read-only", ["git", "log", "--oneline", "-10"]), - task("repository.branch", "Show the current Git branch.", "read-only", ["git", "branch", "--show-current"]), - task("repository.fetch", "Fetch remote refs without modifying the working tree.", "remote", ["git", "fetch", "--prune"]), - task("quality.format-check", "Check repository formatting.", "read-only", ["npm", "run", "format", "--", "--check"]), - task("quality.lint", "Run the project's lint command.", "read-only", ["npm", "run", "lint"]), - task("quality.typecheck", "Run the project's type check.", "read-only", ["npm", "run", "typecheck"]), - task("quality.test", "Run the project's test command.", "read-only", ["npm", "test"]), - task("quality.build", "Run the project's production build.", "working-tree", ["npm", "run", "build"]), - task("dependencies.outdated", "List outdated package dependencies.", "read-only", ["npm", "outdated"]), - task("dependencies.audit", "Audit package dependency advisories.", "read-only", ["npm", "audit", "--audit-level=high"]), - task("dependencies.install", "Install declared dependencies.", "working-tree", ["npm", "install"]), - task("dependencies.update", "Update dependencies within declared ranges.", "working-tree", ["npm", "update"]), - { id: "dependencies.remove", description: "Remove a named package dependency.", risk: "working-tree", inputs: [{ name: "package", description: "Package name to remove.", required: true }], steps: [{ argv: ["npm", "remove", "{{package}}"] }] }, +const recipe = createTaskRecipe; + +const repositoryTasks: readonly TaskRecipe[] = [ + recipe({ id: "repository.status", description: "Show working-tree status.", risk: "read-only", argv: ["git", "status", "--short"] }), + recipe({ id: "repository.diff", description: "Show the current diff.", risk: "read-only", argv: ["git", "diff", "--"] }), + recipe({ id: "repository.log", description: "Show recent commits.", risk: "read-only", argv: ["git", "log", "--oneline", "-10"] }), + recipe({ id: "repository.branch", description: "Show the current Git branch.", risk: "read-only", argv: ["git", "branch", "--show-current"] }), + recipe({ id: "repository.remotes", description: "Show configured Git remotes.", risk: "read-only", argv: ["git", "remote", "-v"] }), + recipe({ id: "repository.stash-list", description: "List Git stashes without applying them.", risk: "read-only", argv: ["git", "stash", "list"] }), + recipe({ id: "repository.shortlog", description: "Show recent author activity.", risk: "read-only", argv: ["git", "shortlog", "-sn", "-10"] }), + recipe({ id: "repository.fetch", description: "Fetch remote refs without modifying the working tree.", risk: "remote", argv: ["git", "fetch", "--prune"] }), + recipe({ id: "quality.format-check", description: "Check repository formatting.", risk: "read-only", argv: ["npm", "run", "format", "--", "--check"], preconditions: ["package.json"] }), + recipe({ id: "quality.lint", description: "Run the project's lint command.", risk: "read-only", argv: ["npm", "run", "lint"], preconditions: ["package.json"] }), + recipe({ id: "quality.typecheck", description: "Run the project's type check.", risk: "read-only", argv: ["npm", "run", "typecheck"], preconditions: ["package.json"] }), + recipe({ id: "quality.test", description: "Run the project's test command.", risk: "read-only", argv: ["npm", "test"], preconditions: ["package.json"] }), + recipe({ id: "quality.build", description: "Run the project's production build.", risk: "working-tree", argv: ["npm", "run", "build"], preconditions: ["package.json"] }), + recipe({ id: "dependencies.outdated", description: "List outdated package dependencies.", risk: "read-only", argv: ["npm", "outdated"], preconditions: ["package.json"] }), + recipe({ id: "dependencies.audit", description: "Audit package dependency advisories.", risk: "read-only", argv: ["npm", "audit", "--audit-level=high"], preconditions: ["package.json"] }), + recipe({ id: "dependencies.install", description: "Install declared dependencies.", risk: "working-tree", argv: ["npm", "install"], preconditions: ["package.json"] }), + recipe({ id: "dependencies.update", description: "Update dependencies within declared ranges.", risk: "working-tree", argv: ["npm", "update"], preconditions: ["package.json"] }), + recipe({ + id: "dependencies.remove", + description: "Remove a named package dependency.", + risk: "working-tree", + inputs: [{ name: "package", description: "Package name to remove.", required: true }], + preconditions: ["package.json"], + steps: [{ argv: ["npm", "remove", "{{package}}"] }], + }), ]; -function specialist(id: string, description: string, responsibility: string): AgentDefinition { - return { id, version: "1.0.0", activation: "passive", security: "content", fallback: "degrade", description, - instructions: `You are the ${id} specialist installed by mstack. ${responsibility}\n\nRead repository evidence before making claims. State verified facts separately from inference. Do not execute external actions or modify code unless the user explicitly requests it. Hand off decisions outside your specialty to the owning specialist.` }; -} -function skill(id: string, description: string): SkillDefinition { return { id, version: "1.0.0", activation: "explicit", security: "content", fallback: "degrade", description, instructions: `Use ${id} to produce an evidence-backed, bounded result. Inspect code, tests, configuration, and documentation; distinguish facts from inference and name the next safe handoff.` }; } -function pack(id: string, displayName: string, description: string, agent: AgentDefinition, skills: readonly string[], dependencies?: readonly string[]): CapabilityPack { - return { id, version: "1.0.0", displayName, description, ...(dependencies === undefined ? {} : { dependencies }), risks: ["content-only runtime resources", "task recipes are policy-gated"], - createSpec: () => ({ agents: [agent], skills: skills.map((name) => skill(name, `${displayName} workflow guidance.`)) }), tasks: [] }; -} export const builtInPacks: readonly CapabilityPack[] = [ - { ...pack("repository-intelligence", "Repository intelligence", "Evidence-backed repository exploration, explanation, and improvement planning.", specialist("repository-explorer", "Maps entry points, modules, build tooling, and risk areas.", "Create repository maps with citations to observable files."), ["repository-exploration", "code-explanation", "change-impact-analysis", "dependency-hygiene"]), - createSpec: () => ({ agents: [ - specialist("repository-explorer", "Maps entry points, modules, build tooling, and risk areas.", "Create repository maps with citations to observable files."), - specialist("code-explainer", "Traces a selected path, symbol, flow, or subsystem.", "Explain execution paths and explicitly label inference."), - specialist("improvement-advisor", "Produces prioritized and scoped improvement plans.", "Recommend changes but never silently modify code."), - specialist("dependency-analyst", "Audits dependency purpose, freshness, and risk.", "Report evidence and compatibility constraints."), - specialist("build-system-diagnostician", "Explains build, test, and CI configuration.", "Diagnose from configuration and reproducible commands."), - ], skills: ["repository-exploration", "code-explanation", "change-impact-analysis", "dependency-hygiene"].map((name) => skill(name, "Repository intelligence workflow guidance.")) }), tasks: coreTasks }, - pack("software-web", "Software and web", "Web application implementation and accessibility specialists.", specialist("web-engineer", "Builds accessible web journeys against explicit contracts.", "Keep client state and server contracts distinct."), ["web-delivery", "accessibility-verification"]), - pack("systems", "Systems", "Systems design, service reliability, and toolchain guidance.", specialist("systems-engineer", "Designs robust systems boundaries and failure handling.", "Keep operational and performance assumptions explicit."), ["system-design", "toolchain-discovery"]), - pack("embedded-firmware", "Embedded and firmware", "Firmware delivery and hardware-boundary verification.", specialist("embedded-engineer", "Owns firmware behavior, constraints, and safe verification.", "Do not claim hardware validation without observable evidence."), ["firmware-delivery", "hardware-in-the-loop-verification"], ["systems"]), - pack("robotics", "Robotics", "Robotics safety, simulation, and hardware-in-the-loop guidance.", specialist("robotics-engineer", "Owns safety boundaries and robot-system integration.", "Require simulation and explicit human authorization before consequential motion."), ["robotics-safety", "simulation-validation", "hardware-in-the-loop-verification"], ["systems", "embedded-firmware"]), - pack("data-ml", "Data and ML", "Data and model workflow guidance.", specialist("ml-engineer", "Owns reproducible data and model delivery.", "Treat model output and data as untrusted inputs."), ["data-pipeline-design", "model-evaluation"]), - pack("mobile", "Mobile", "Mobile platform and release journey guidance.", specialist("mobile-engineer", "Owns mobile client behavior and platform constraints.", "Keep platform permissions and offline behavior explicit."), ["mobile-delivery"]), - pack("games-graphics", "Games and graphics", "Rendering, simulation, and performance guidance.", specialist("graphics-engineer", "Owns graphics performance and visual correctness.", "Measure frame-time effects before optimization."), ["graphics-performance"]), - pack("infrastructure-security", "Infrastructure and security", "Infrastructure reliability and security review guidance.", specialist("security-engineer", "Owns threat analysis and infrastructure safety.", "Use least privilege and explicit trust boundaries."), ["threat-model-review", "infrastructure-delivery"]), - pack("cli-devtools", "CLI and devtools", "Developer-tool UX and command-contract guidance.", specialist("devtools-engineer", "Owns command contracts and developer workflows.", "Keep output stable, safe, and automation-friendly."), ["cli-contract-design"]), + createCapabilityPack({ + id: "repository-intelligence", + displayName: "Repository intelligence", + description: "Evidence-backed repository exploration, explanation, and improvement planning.", + requiredToolchains: ["git"], + agents: [ + createSpecialist("repository-explorer", "Maps entry points, modules, build tooling, and risk areas.", "Create repository maps with citations to observable files."), + createSpecialist("code-explainer", "Traces a selected path, symbol, flow, or subsystem.", "Explain execution paths and explicitly label inference."), + createSpecialist("improvement-advisor", "Produces prioritized and scoped improvement plans.", "Recommend changes but never silently modify code."), + createSpecialist("dependency-analyst", "Audits dependency purpose, freshness, and risk.", "Report evidence and compatibility constraints."), + createSpecialist("build-system-diagnostician", "Explains build, test, and CI configuration.", "Diagnose from configuration and reproducible commands."), + createSpecialist("repository-historian", "Summarizes recent change patterns and ownership hotspots.", "Use git history as evidence; do not rewrite history."), + ], + skills: [ + createPackSkill("repository-exploration", "Map a repository from observable files.", "Produce a cited map of entry points, modules, and generated vs owned files."), + createPackSkill("code-explanation", "Explain a selected path or symbol.", "Trace control flow from source and tests. Label inference separately."), + createPackSkill("change-impact-analysis", "Estimate blast radius for a proposed change.", "Name affected contracts, tests, and operators before recommending an edit."), + createPackSkill("dependency-hygiene", "Review dependency purpose and risk.", "Prefer removing unused dependencies over adding new ones without a requirement."), + createPackSkill("history-survey", "Survey recent commits for ownership and risk.", "Cite commits and paths. Do not interpret commit messages as product evidence."), + ], + prompts: [ + createPackPrompt( + "map-repository", + "Produce an evidence-backed map of the current repository.", + "[optional focus path]", + "Map entry points, module boundaries, build/test commands, and the highest-risk areas. Cite files. Do not modify the working tree.", + ), + ], + tasks: repositoryTasks, + }), + createCapabilityPack({ + id: "software-web", + displayName: "Software and web", + description: "Web application implementation, client-state, and accessibility specialists.", + requiredToolchains: ["node"], + agents: [ + createSpecialist("web-engineer", "Builds accessible web journeys against explicit contracts.", "Keep client state and server contracts distinct."), + createSpecialist("accessibility-specialist", "Reviews keyboard, semantics, contrast, and reduced-motion behavior.", "Do not treat visual polish as accessibility evidence."), + createSpecialist("client-platform-analyst", "Explains bundler, routing, and hydration constraints.", "Diagnose from configuration and reproducible commands."), + ], + skills: [ + createPackSkill("web-delivery", "Deliver an accessible web slice against a contract.", "Implement loading, empty, validation, unauthorized, and failure states."), + createPackSkill("accessibility-verification", "Verify keyboard and semantic access.", "Check names, roles, focus, and contrast against the product's accessibility target."), + createPackSkill("client-state-separation", "Keep server, form, and presentation state distinct.", "Do not duplicate server authorization in the client as the source of truth."), + ], + prompts: [ + createPackPrompt( + "review-web-journey", + "Review a web user journey for contract and accessibility gaps.", + "[journey or route]", + "Inspect the selected journey against the backend contract. Report missing UI states, accessibility gaps, and unauthorized data exposure. Do not rewrite unrelated styling.", + ), + ], + tasks: [ + recipe({ id: "web.tsc", description: "Typecheck with the local TypeScript compiler.", risk: "read-only", argv: ["npx", "--no-install", "tsc", "--noEmit"], preconditions: ["package.json", "tsconfig.json"] }), + recipe({ id: "web.eslint", description: "Lint with the local ESLint installation.", risk: "read-only", argv: ["npx", "--no-install", "eslint", "."], preconditions: ["package.json"] }), + recipe({ id: "web.playwright-list", description: "List Playwright tests without running them.", risk: "read-only", argv: ["npx", "--no-install", "playwright", "test", "--list"], preconditions: ["package.json"] }), + recipe({ id: "web.vite-build", description: "Build with the local Vite installation.", risk: "working-tree", argv: ["npx", "--no-install", "vite", "build"], preconditions: ["package.json"] }), + ], + }), + createCapabilityPack({ + id: "systems", + displayName: "Systems", + description: "Systems design, service reliability, and native toolchain guidance.", + requiredToolchains: ["cargo", "go"], + agents: [ + createSpecialist("systems-engineer", "Designs robust systems boundaries and failure handling.", "Keep operational and performance assumptions explicit."), + createSpecialist("reliability-boundary-analyst", "Reviews timeouts, retries, and degraded modes.", "Do not recommend distributed complexity without a measured trigger."), + createSpecialist("toolchain-cartographer", "Maps language toolchains and build graphs.", "Cite configuration files; do not assume a toolchain is installed."), + ], + skills: [ + createPackSkill("system-design", "Design explicit system boundaries.", "Record actors, failure behavior, and the smallest deployable unit that satisfies the product."), + createPackSkill("toolchain-discovery", "Discover language toolchains from repository evidence.", "Prefer declared tool versions over ambient machine defaults."), + createPackSkill("failure-mode-review", "Review timeout, retry, and degradation behavior.", "Bounded retries apply only to transient failures."), + ], + prompts: [ + createPackPrompt( + "review-system-boundaries", + "Review module and process boundaries against failure behavior.", + "[subsystem]", + "Identify ownership, synchronous vs asynchronous edges, and what happens when a dependency fails. Recommend the smallest boundary change that reduces risk.", + ), + ], + tasks: [ + recipe({ id: "systems.cargo-check", description: "Typecheck a Rust crate without running tests.", risk: "working-tree", argv: ["cargo", "check", "--locked"], preconditions: ["Cargo.toml"] }), + recipe({ id: "systems.cargo-test", description: "Run Rust tests.", risk: "working-tree", argv: ["cargo", "test", "--locked"], preconditions: ["Cargo.toml"] }), + recipe({ id: "systems.go-vet", description: "Run Go vet on the module.", risk: "read-only", argv: ["go", "vet", "./..."], preconditions: ["go.mod"] }), + recipe({ id: "systems.go-test", description: "Run Go tests for the module.", risk: "working-tree", argv: ["go", "test", "./..."], preconditions: ["go.mod"] }), + ], + }), + createCapabilityPack({ + id: "embedded-firmware", + displayName: "Embedded and firmware", + description: "Firmware delivery and hardware-boundary verification.", + dependencies: ["systems"], + requiredToolchains: ["pio", "west"], + agents: [ + createSpecialist("embedded-engineer", "Owns firmware behavior, constraints, and safe verification.", "Do not claim hardware validation without observable evidence."), + createSpecialist("hardware-interface-analyst", "Reviews pinouts, buses, and fail-safe defaults.", "Treat datasheets and schematic notes as untrusted until cited."), + ], + skills: [ + createPackSkill("firmware-delivery", "Deliver firmware changes with hardware constraints explicit.", "Keep boot, brownout, and watchdog behavior in the design, not as afterthoughts."), + createPackSkill("hardware-in-the-loop-verification", "Plan hardware-in-the-loop checks.", "Do not claim bench evidence that was not collected. Prefer simulation first."), + ], + prompts: [ + createPackPrompt( + "review-firmware-constraints", + "Review firmware constraints, fail-safes, and unverified hardware claims.", + "[board or firmware target]", + "List power, memory, timing, and fail-safe constraints from repository evidence. Flag any claim that requires bench hardware.", + ), + ], + tasks: [ + recipe({ id: "firmware.pio-config", description: "Show PlatformIO project configuration.", risk: "read-only", argv: ["pio", "project", "config"], preconditions: ["platformio.ini"] }), + recipe({ id: "firmware.west-list", description: "List west workspace projects.", risk: "read-only", argv: ["west", "list"], preconditions: ["west.yml"] }), + ], + }), + createCapabilityPack({ + id: "robotics", + displayName: "Robotics", + description: "Robotics safety, simulation, and hardware-in-the-loop guidance.", + dependencies: ["systems", "embedded-firmware"], + requiredToolchains: ["ros2", "colcon"], + agents: [ + createSpecialist("robotics-engineer", "Owns safety boundaries and robot-system integration.", "Require simulation and explicit human authorization before consequential motion."), + createSpecialist("simulation-safety-analyst", "Reviews simulation coverage versus real-world motion risk.", "Never treat a passing sim as permission to move hardware."), + ], + skills: [ + createPackSkill("robotics-safety", "Keep robot motion behind explicit authorization.", "Consequential actuators require a human confirmation policy."), + createPackSkill("simulation-validation", "Validate behavior in simulation before hardware.", "Record which hazards the sim cannot represent."), + ], + prompts: [ + createPackPrompt( + "review-robot-safety", + "Review robotics safety boundaries and simulation gaps.", + "[robot or launch target]", + "Identify motion, estop, and authorization boundaries. Name simulation gaps. Do not generate hardware motion commands.", + ), + ], + tasks: [ + recipe({ id: "robotics.ros2-doctor", description: "Run ROS 2 doctor without changing the workspace.", risk: "read-only", argv: ["ros2", "doctor"] }), + recipe({ id: "robotics.colcon-list", description: "List colcon packages.", risk: "read-only", argv: ["colcon", "list"] }), + ], + }), + createCapabilityPack({ + id: "data-ml", + displayName: "Data and ML", + description: "Data and model workflow guidance.", + requiredToolchains: ["python"], + agents: [ + createSpecialist("ml-engineer", "Owns reproducible data and model delivery.", "Treat model output and data as untrusted inputs."), + createSpecialist("data-pipeline-analyst", "Reviews schemas, late events, and backfill safety.", "Make backfills restartable and explicit about overwrite risk."), + createSpecialist("evaluation-analyst", "Owns evaluation datasets, thresholds, and leakage checks.", "Do not ship a cheaper model without re-running the documented eval."), + ], + skills: [ + createPackSkill("data-pipeline-design", "Design explicit data contracts and recovery.", "Document ownership, retention, and what happens when an event arrives late."), + createPackSkill("model-evaluation", "Evaluate models against a versioned dataset.", "Report unanswerable, safety, latency, and cost cases, not only accuracy."), + createPackSkill("training-reproducibility", "Check seeds, splits, and artifact versions.", "Refuse to treat an unversioned notebook as production evidence."), + ], + prompts: [ + createPackPrompt( + "evaluate-model-change", + "Evaluate a model or data-pipeline change against documented thresholds.", + "[model, dataset, or pipeline]", + "Compare the change to the versioned evaluation contract. Report leakage risk, failed cases, and whether a cheaper model still meets the bar.", + ), + ], + tasks: [ + recipe({ id: "ml.compileall", description: "Byte-compile Python sources without executing them.", risk: "read-only", argv: ["python", "-m", "compileall", "-q", "."] }), + recipe({ id: "ml.ruff", description: "Lint Python with Ruff.", risk: "read-only", argv: ["python", "-m", "ruff", "check", "."] }), + recipe({ id: "ml.mypy", description: "Typecheck Python with mypy.", risk: "read-only", argv: ["python", "-m", "mypy", "."] }), + recipe({ id: "ml.pytest", description: "Run pytest quietly.", risk: "working-tree", argv: ["python", "-m", "pytest", "-q"] }), + ], + }), + createCapabilityPack({ + id: "mobile", + displayName: "Mobile", + description: "Mobile platform, permission, and release journey guidance.", + requiredToolchains: ["flutter"], + agents: [ + createSpecialist("mobile-engineer", "Owns mobile client behavior and platform constraints.", "Keep platform permissions and offline behavior explicit."), + createSpecialist("store-release-analyst", "Reviews store privacy labels, permissions, and release evidence.", "Do not treat an emulator pass as store-readiness."), + ], + skills: [ + createPackSkill("mobile-delivery", "Deliver a mobile journey with offline and permission states.", "Implement denied-permission and offline queues as first-class states."), + createPackSkill("store-release-review", "Review store and privacy-label requirements.", "Cite the product's data collection claims before a store submission."), + ], + prompts: [ + createPackPrompt( + "review-mobile-release", + "Review a mobile change for permission, offline, and store-release gaps.", + "[screen or platform]", + "Inspect permission denial, offline capture, and privacy-label impact. Do not assume App Store or Play Console access.", + ), + ], + tasks: [ + recipe({ id: "mobile.flutter-analyze", description: "Analyze a Flutter project.", risk: "read-only", argv: ["flutter", "analyze"], preconditions: ["pubspec.yaml"] }), + recipe({ id: "mobile.flutter-test", description: "Run Flutter tests.", risk: "working-tree", argv: ["flutter", "test"], preconditions: ["pubspec.yaml"] }), + recipe({ id: "mobile.flutter-devices", description: "List Flutter devices without launching an app.", risk: "read-only", argv: ["flutter", "devices"] }), + ], + }), + createCapabilityPack({ + id: "games-graphics", + displayName: "Games and graphics", + description: "Rendering, simulation, and frame-budget guidance.", + agents: [ + createSpecialist("graphics-engineer", "Owns graphics performance and visual correctness.", "Measure frame-time effects before optimization."), + createSpecialist("frame-budget-analyst", "Reviews CPU/GPU budgets and asset cost.", "Do not recommend engine changes without a measured budget miss."), + ], + skills: [ + createPackSkill("graphics-performance", "Investigate frame-time and visual correctness.", "Profile first. Record the scene, hardware class, and budget."), + createPackSkill("asset-budget-review", "Review texture, mesh, and shader cost.", "Prefer content budgets over speculative engine rewrites."), + ], + prompts: [ + createPackPrompt( + "review-frame-budget", + "Review a graphics change against a measured frame budget.", + "[scene or pass]", + "Identify the budget, the measurement method, and whether the change is content, CPU, or GPU bound. Do not optimize from intuition.", + ), + ], + tasks: [ + recipe({ id: "graphics.godot-version", description: "Show the Godot CLI version.", risk: "read-only", argv: ["godot", "--version"], preconditions: ["project.godot"] }), + recipe({ id: "graphics.cargo-clippy", description: "Lint a Rust graphics crate with Clippy.", risk: "working-tree", argv: ["cargo", "clippy", "--locked", "--", "-D", "warnings"], preconditions: ["Cargo.toml"] }), + ], + }), + createCapabilityPack({ + id: "infrastructure-security", + displayName: "Infrastructure and security", + description: "Infrastructure reliability and security review guidance.", + requiredToolchains: ["terraform", "docker"], + agents: [ + createSpecialist("security-engineer", "Owns threat analysis and infrastructure safety.", "Use least privilege and explicit trust boundaries."), + createSpecialist("infrastructure-operator", "Reviews IaC, images, and runtime topology.", "Do not apply infrastructure changes; report the plan and blast radius."), + ], + skills: [ + createPackSkill("infrastructure-threat-review", "Threat-model infrastructure and delivery paths.", "Stay inside the product's trust boundaries. Do not invent compliance regimes."), + createPackSkill("infrastructure-delivery", "Review delivery topology and recovery.", "Prefer additive, reversible infrastructure changes."), + createPackSkill("container-hygiene", "Review image and compose configuration.", "Flag secrets in images, latest tags, and privileged containers."), + ], + prompts: [ + createPackPrompt( + "review-infra-threats", + "Review infrastructure and trust-boundary changes.", + "[service or environment]", + "Identify actors, secrets, network edges, and recoverable failure. Do not apply Terraform, Kubernetes, or cloud mutations.", + ), + ], + tasks: [ + recipe({ id: "infra.tf-fmt-check", description: "Check Terraform formatting without rewriting files.", risk: "read-only", argv: ["terraform", "fmt", "-check", "-recursive"], preconditions: ["main.tf"] }), + recipe({ id: "infra.compose-config", description: "Validate Compose configuration.", risk: "read-only", argv: ["docker", "compose", "config"], preconditions: ["compose.yaml"] }), + recipe({ id: "infra.hadolint", description: "Lint a Dockerfile with hadolint.", risk: "read-only", argv: ["hadolint", "Dockerfile"], preconditions: ["Dockerfile"] }), + recipe({ id: "infra.docker-images", description: "List local Docker images.", risk: "read-only", argv: ["docker", "images"] }), + ], + }), + createCapabilityPack({ + id: "cli-devtools", + displayName: "CLI and devtools", + description: "Developer-tool UX and command-contract guidance.", + requiredToolchains: ["node"], + agents: [ + createSpecialist("devtools-engineer", "Owns command contracts and developer workflows.", "Keep output stable, safe, and automation-friendly."), + createSpecialist("command-contract-analyst", "Reviews flags, exit codes, and JSON schemas.", "Do not break machine-readable output for cosmetic reasons."), + ], + skills: [ + createPackSkill("cli-contract-design", "Design stable CLI contracts.", "Define exit codes, JSON schemas, and non-interactive approval rules before implementation."), + createPackSkill("developer-workflow-review", "Review local developer loops.", "Prefer one obvious happy path and explicit dry runs over hidden defaults."), + ], + prompts: [ + createPackPrompt( + "review-cli-contract", + "Review a CLI command for stability, safety, and automation use.", + "[command]", + "Inspect help, flags, exit codes, and JSON. Report breaking-change risk and missing dry-run or approval behavior.", + ), + ], + tasks: [ + recipe({ id: "devtools.pkg-bin", description: "Show package.bin from package.json.", risk: "read-only", argv: ["npm", "pkg", "get", "bin"], preconditions: ["package.json"] }), + recipe({ id: "devtools.node-test", description: "Run Node's built-in test runner.", risk: "read-only", argv: ["node", "--test"] }), + recipe({ id: "devtools.help", description: "Print this CLI's help.", risk: "read-only", argv: ["node", "--help"] }), + ], + }), + createCapabilityPack({ + id: "backend-api", + displayName: "Backend and API", + description: "Server contracts, authorization, and persistence-boundary guidance.", + requiredToolchains: ["node"], + agents: [ + createSpecialist("api-boundary-engineer", "Owns HTTP and event contracts without leaking persistence models.", "Define authentication, authorization, idempotency, and errors before clients integrate."), + createSpecialist("persistence-contract-analyst", "Reviews schema ownership, constraints, and expand-and-contract changes.", "Never edit an applied production migration to rewrite history."), + ], + skills: [ + createPackSkill("api-boundary-design", "Design stable request, response, and error schemas.", "Keep Prisma and vendor types behind the owning module."), + createPackSkill("authz-operation-review", "Review resource-level authorization per operation.", "Authentication is not authorization. Deny by default."), + createPackSkill("schema-expand-contract", "Plan additive schema changes.", "Prefer expand-and-contract over destructive cutovers."), + ], + prompts: [ + createPackPrompt( + "review-api-boundary", + "Review an API operation for contract, auth, and compatibility risk.", + "[operation or route]", + "Check request/response schemas, authorization, idempotency, and error mapping. Do not expose database models.", + ), + ], + tasks: [ + recipe({ id: "api.prisma-validate", description: "Validate a Prisma schema.", risk: "read-only", argv: ["npx", "--no-install", "prisma", "validate"], preconditions: ["package.json", "prisma/schema.prisma"] }), + recipe({ id: "api.contract-files", description: "List tracked API contract files.", risk: "read-only", argv: ["git", "ls-files", "--", "openapi.json", "openapi.yaml", "prisma/schema.prisma"] }), + recipe({ id: "api.prisma-version", description: "Show the local Prisma CLI version.", risk: "read-only", argv: ["npx", "--no-install", "prisma", "-v"], preconditions: ["package.json"] }), + ], + }), + createCapabilityPack({ + id: "qa-testing", + displayName: "QA and testing", + description: "Independent verification, coverage, and regression guidance.", + agents: [ + createSpecialist("verification-strategist", "Owns risk-based test selection and acceptance evidence.", "Do not duplicate the implementer's happy-path tests as the only evidence."), + createSpecialist("regression-analyst", "Reviews flaky tests, missing failure paths, and coverage gaps.", "A skipped failure path is a product risk, not a cleanup item."), + ], + skills: [ + createPackSkill("verification-planning", "Plan verification from user risk, not folder structure.", "Cover success, validation, denial, failure, and recovery at the level that proves behavior."), + createPackSkill("flaky-test-triage", "Triage flaky tests without deleting signal.", "Do not weaken or delete a meaningful test merely to make a change pass."), + ], + prompts: [ + createPackPrompt( + "plan-verification", + "Plan independent verification for a change.", + "[change or feature]", + "List the important success, denial, failure, and recovery paths. Name the smallest tests that prove them. Do not implement the feature in this pass.", + ), + ], + tasks: [ + recipe({ id: "qa.node-test", description: "Run Node's built-in test runner recursively.", risk: "read-only", argv: ["node", "--test"] }), + recipe({ id: "qa.pytest", description: "Run pytest quietly.", risk: "working-tree", argv: ["python", "-m", "pytest", "-q"] }), + recipe({ id: "qa.coverage", description: "Run the npm test script with coverage arguments.", risk: "working-tree", argv: ["npm", "test", "--", "--coverage"], preconditions: ["package.json"] }), + recipe({ id: "qa.playwright-list", description: "List Playwright tests without running them.", risk: "read-only", argv: ["npx", "--no-install", "playwright", "test", "--list"], preconditions: ["package.json"] }), + ], + }), + createCapabilityPack({ + id: "observability", + displayName: "Observability", + description: "Telemetry, health, and incident-evidence guidance.", + requiredToolchains: ["docker"], + agents: [ + createSpecialist("telemetry-engineer", "Owns logs, metrics, and traces without collecting secrets.", "Redact credentials, tokens, payment data, and unnecessary personal content."), + createSpecialist("incident-analyst", "Reviews degraded modes and operator evidence.", "Prefer actionable error reports over verbose debug dumps."), + ], + skills: [ + createPackSkill("telemetry-design", "Design structured observability for critical paths.", "Include request or job identifiers and outcome metrics. Omit secrets."), + createPackSkill("degraded-mode-review", "Review operator-visible degraded behavior.", "Define rollback or forward recovery for risky changes."), + ], + prompts: [ + createPackPrompt( + "review-telemetry", + "Review a change for missing operator evidence and secret leakage in logs.", + "[service or job]", + "Identify the critical path, required identifiers, and redaction gaps. Do not add a new vendor without a product reason.", + ), + ], + tasks: [ + recipe({ id: "obs.compose-config", description: "Validate Compose configuration used for local telemetry stacks.", risk: "read-only", argv: ["docker", "compose", "config"] }), + recipe({ id: "obs.compose-ps", description: "Show Compose service status.", risk: "read-only", argv: ["docker", "compose", "ps"] }), + recipe({ id: "obs.docker-ps", description: "List running containers.", risk: "read-only", argv: ["docker", "ps"] }), + ], + }), + createCapabilityPack({ + id: "documentation", + displayName: "Documentation", + description: "Product, architecture, and changelog stewardship.", + agents: [ + createSpecialist("docs-engineer", "Keeps product and architecture documents aligned with shipped behavior.", "Do not treat templates as requirements. Update docs in the same change as behavior."), + createSpecialist("changelog-steward", "Reviews user-facing change notes and migration copy.", "Keep current behavior separate from future direction."), + ], + skills: [ + createPackSkill("source-of-truth-alignment", "Align docs with executable behavior.", "If documents conflict with deployed behavior, identify the intended source of truth."), + createPackSkill("changelog-hygiene", "Write user-facing change notes.", "Record verification evidence and residual risk, not a file list."), + ], + prompts: [ + createPackPrompt( + "review-project-docs", + "Review product and architecture docs against current behavior.", + "[document or change]", + "Find contradictions, missing actors, and undocumented failure behavior. Do not invent user evidence.", + ), + ], + tasks: [ + recipe({ id: "docs.list", description: "List tracked documentation files.", risk: "read-only", argv: ["git", "ls-files", "--", "docs", "README.md"] }), + recipe({ id: "docs.diff", description: "Show documentation diffs.", risk: "read-only", argv: ["git", "diff", "--", "docs", "README.md"] }), + recipe({ id: "docs.grep-todo", description: "Find TODO markers in documentation.", risk: "read-only", argv: ["git", "grep", "-n", "TODO", "--", "docs"] }), + ], + }), ]; diff --git a/packages/cli/src/packs/define.ts b/packages/cli/src/packs/define.ts new file mode 100644 index 0000000..93abcc7 --- /dev/null +++ b/packages/cli/src/packs/define.ts @@ -0,0 +1,110 @@ +import type { AgentDefinition, PromptDefinition, SkillDefinition } from "../../../ai-integrations/src/index.js"; +import type { CapabilityPack, TaskRecipe, TaskRisk } from "./types.js"; + +export function createTaskRecipe(options: { + id: string; + description: string; + risk: TaskRisk; + argv?: readonly string[]; + steps?: TaskRecipe["steps"]; + inputs?: TaskRecipe["inputs"]; + preconditions?: readonly string[]; + timeoutMs?: number; + cwd?: string; +}): TaskRecipe { + const steps = options.steps ?? (options.argv === undefined ? [] : [{ argv: options.argv }]); + return { + id: options.id, + description: options.description, + risk: options.risk, + steps, + ...(options.inputs === undefined ? {} : { inputs: options.inputs }), + ...(options.preconditions === undefined ? {} : { preconditions: options.preconditions }), + ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), + }; +} + +export function createSpecialist(id: string, description: string, responsibility: string): AgentDefinition { + return { + id, + version: "1.0.0", + activation: "passive", + security: "content", + fallback: "degrade", + description, + instructions: [ + `You are the ${id} specialist installed by mstack. ${responsibility}`, + "", + "Read repository evidence before making claims. State verified facts separately from inference.", + "Do not execute external actions, mutate production, or modify code unless the user explicitly requests it.", + "Hand off decisions outside your specialty to the owning specialist. Prefer mstack task recipes over undeclared shell programs.", + ].join("\n"), + }; +} + +export function createPackSkill(id: string, description: string, guidance: string): SkillDefinition { + return { + id, + version: "1.0.0", + activation: "explicit", + security: "content", + fallback: "degrade", + description, + instructions: [ + guidance, + "", + "Inspect code, tests, configuration, and documentation. Distinguish facts from inference.", + "Name the next safe handoff. Do not host inference, mutate production, or run undeclared shell programs.", + ].join("\n"), + }; +} + +export function createPackPrompt(id: string, description: string, argumentHint: string, body: string): PromptDefinition { + return { + id, + version: "1.0.0", + activation: "passive", + security: "content", + fallback: "degrade", + description, + argumentHint, + prompt: [ + `# ${id}`, + "", + body.trim(), + "", + "The repository is the host project. Build Like This is the method; mstack only installs resources.", + "Use project-owned docs, code, and tests as sources of truth. Label inference separately from evidence.", + "Do not execute undeclared shell programs, deploy, or contact external systems. Finish with verification evidence and residual risk.", + ].join("\n"), + }; +} + +export function createCapabilityPack(options: { + id: string; + displayName: string; + description: string; + agents: readonly AgentDefinition[]; + skills: readonly SkillDefinition[]; + prompts?: readonly PromptDefinition[]; + tasks: readonly TaskRecipe[]; + dependencies?: readonly string[]; + requiredToolchains?: readonly string[]; +}): CapabilityPack { + return { + id: options.id, + version: "1.0.0", + displayName: options.displayName, + description: options.description, + ...(options.dependencies === undefined ? {} : { dependencies: options.dependencies }), + ...(options.requiredToolchains === undefined ? {} : { requiredToolchains: options.requiredToolchains }), + risks: ["content-only runtime resources", "task recipes are policy-gated"], + createSpec: () => ({ + agents: [...options.agents], + skills: [...options.skills], + ...(options.prompts === undefined ? {} : { prompts: [...options.prompts] }), + }), + tasks: options.tasks, + }; +} diff --git a/packages/cli/src/packs/index.ts b/packages/cli/src/packs/index.ts index 30094f2..82c499b 100644 --- a/packages/cli/src/packs/index.ts +++ b/packages/cli/src/packs/index.ts @@ -1,5 +1,7 @@ import { builtInPacks } from "./builtins.js"; import { PackRegistry } from "./registry.js"; export { builtInPacks } from "./builtins.js"; +export { recommendPacks, type PackRecommendation, type PackRecommendReport } from "./recommend.js"; export type * from "./types.js"; +export { TASK_RISKS } from "./types.js"; export function createDefaultPackRegistry(): PackRegistry { return new PackRegistry(builtInPacks); } diff --git a/packages/cli/src/packs/recommend.ts b/packages/cli/src/packs/recommend.ts new file mode 100644 index 0000000..f7efb88 --- /dev/null +++ b/packages/cli/src/packs/recommend.ts @@ -0,0 +1,160 @@ +import path from "node:path"; +import { pathExists, readJson } from "../core/fs.js"; +import { builtInPacks } from "./builtins.js"; +import { PackRegistry } from "./registry.js"; +import { readManifest } from "../services/manifest.js"; + +export type RecommendationConfidence = "high" | "medium" | "low"; + +export interface PackRecommendation { + readonly id: string; + readonly displayName: string; + readonly description: string; + readonly reasons: readonly string[]; + readonly confidence: RecommendationConfidence; + readonly alreadySelected: boolean; +} + +export interface PackRecommendReport { + readonly schemaVersion: 1; + readonly root: string; + readonly recommendations: readonly PackRecommendation[]; +} + +interface PackageManifest { + readonly bin?: unknown; + readonly dependencies?: Readonly>; + readonly devDependencies?: Readonly>; + readonly optionalDependencies?: Readonly>; + readonly scripts?: Readonly>; +} + +const markerReasons: readonly { file: string; pack: string; reason: string }[] = [ + { file: "package.json", pack: "repository-intelligence", reason: "package.json describes Node package metadata." }, + { file: "Cargo.toml", pack: "systems", reason: "Cargo.toml indicates a Rust workspace or crate." }, + { file: "go.mod", pack: "systems", reason: "go.mod indicates a Go module." }, + { file: "go.mod", pack: "cli-devtools", reason: "Go modules commonly ship command-line tools." }, + { file: "pyproject.toml", pack: "data-ml", reason: "pyproject.toml indicates a Python project." }, + { file: "requirements.txt", pack: "data-ml", reason: "requirements.txt lists Python dependencies." }, + { file: "Dockerfile", pack: "infrastructure-security", reason: "Dockerfile describes a container build." }, + { file: "Dockerfile", pack: "observability", reason: "Container images usually need health and log guidance." }, + { file: "compose.yaml", pack: "observability", reason: "Compose files describe local multi-process topology." }, + { file: "compose.yml", pack: "observability", reason: "Compose files describe local multi-process topology." }, + { file: "docker-compose.yml", pack: "infrastructure-security", reason: "docker-compose.yml describes service topology." }, + { file: "docker-compose.yaml", pack: "infrastructure-security", reason: "docker-compose.yaml describes service topology." }, + { file: "platformio.ini", pack: "embedded-firmware", reason: "platformio.ini indicates a firmware project." }, + { file: "west.yml", pack: "embedded-firmware", reason: "west.yml indicates a Zephyr/west workspace." }, + { file: "CMakeLists.txt", pack: "systems", reason: "CMakeLists.txt indicates a native build." }, + { file: "CMakeLists.txt", pack: "embedded-firmware", reason: "CMake is a common firmware build surface." }, + { file: "pubspec.yaml", pack: "mobile", reason: "pubspec.yaml indicates a Flutter or Dart client." }, + { file: "project.godot", pack: "games-graphics", reason: "project.godot indicates a Godot project." }, + { file: "main.tf", pack: "infrastructure-security", reason: "main.tf indicates Terraform configuration." }, + { file: "terraform/main.tf", pack: "infrastructure-security", reason: "terraform/main.tf indicates Terraform configuration." }, + { file: "prisma/schema.prisma", pack: "backend-api", reason: "prisma/schema.prisma indicates a persistence contract." }, + { file: "openapi.yaml", pack: "backend-api", reason: "openapi.yaml describes an HTTP contract." }, + { file: "openapi.json", pack: "backend-api", reason: "openapi.json describes an HTTP contract." }, + { file: "docs", pack: "documentation", reason: "docs/ is present as a documentation tree." }, + { file: "android", pack: "mobile", reason: "android/ indicates a native Android tree." }, + { file: "ios", pack: "mobile", reason: "ios/ indicates a native iOS tree." }, + { file: "package.xml", pack: "robotics", reason: "package.xml may indicate a ROS package." }, + { file: "playwright.config.ts", pack: "qa-testing", reason: "playwright.config.ts indicates browser verification." }, + { file: "playwright.config.js", pack: "qa-testing", reason: "playwright.config.js indicates browser verification." }, + { file: "vitest.config.ts", pack: "qa-testing", reason: "vitest.config.ts indicates a unit/integration test runner." }, + { file: "jest.config.ts", pack: "qa-testing", reason: "jest.config.ts indicates a JavaScript test runner." }, +]; + +const webDependencies = ["next", "react", "vue", "nuxt", "svelte", "remix", "astro", "@angular/core"]; +const apiDependencies = ["express", "fastify", "hono", "@nestjs/core", "prisma", "drizzle-orm", "openapi-typescript"]; +const mobileDependencies = ["react-native", "expo", "@capacitor/core"]; +const gameDependencies = ["three", "pixi.js", "babylonjs"]; +const cliDependencies = ["commander", "citty", "yargs", "cac"]; +const telemetryDependencies = ["pino", "winston", "@opentelemetry/api"]; +const mlDependencies = ["torch", "pytorch", "scikit-learn", "tensorflow", "pandas"]; +const qaDependencies = ["playwright", "cypress", "vitest", "jest", "@testing-library/react"]; + +function dependencyNames(manifest: PackageManifest): Set { + return new Set([ + ...Object.keys(manifest.dependencies ?? {}), + ...Object.keys(manifest.devDependencies ?? {}), + ...Object.keys(manifest.optionalDependencies ?? {}), + ]); +} + +function hasAny(names: Set, candidates: readonly string[]): string | undefined { + return candidates.find((name) => names.has(name)); +} + +function confidenceFor(reasonCount: number): RecommendationConfidence { + if (reasonCount >= 3) return "high"; + if (reasonCount >= 2) return "high"; + return "medium"; +} + +export async function recommendPacks(root: string): Promise { + const registry = new PackRegistry(builtInPacks); + const selected = new Set((await readManifest(root))?.packs?.map((pack) => pack.id) ?? []); + const reasons = new Map(); + const addReason = (pack: string, reason: string) => { + const list = reasons.get(pack) ?? []; + if (!list.includes(reason)) list.push(reason); + reasons.set(pack, list); + }; + + if (await pathExists(path.join(root, ".git"))) { + addReason("repository-intelligence", ".git is present, so repository maps and git recipes apply."); + } + + for (const marker of markerReasons) { + if (await pathExists(path.join(root, marker.file))) addReason(marker.pack, marker.reason); + } + + const packagePath = path.join(root, "package.json"); + if (await pathExists(packagePath)) { + try { + const manifest = await readJson(packagePath); + const names = dependencyNames(manifest); + const web = hasAny(names, webDependencies); + if (web) addReason("software-web", `package.json depends on ${web}.`); + const api = hasAny(names, apiDependencies); + if (api) addReason("backend-api", `package.json depends on ${api}.`); + const mobile = hasAny(names, mobileDependencies); + if (mobile) addReason("mobile", `package.json depends on ${mobile}.`); + const game = hasAny(names, gameDependencies); + if (game) addReason("games-graphics", `package.json depends on ${game}.`); + const cli = hasAny(names, cliDependencies); + if (cli || manifest.bin !== undefined) { + addReason("cli-devtools", cli ? `package.json depends on ${cli}.` : "package.json declares a bin entry."); + } + const telemetry = hasAny(names, telemetryDependencies); + if (telemetry) addReason("observability", `package.json depends on ${telemetry}.`); + const ml = hasAny(names, mlDependencies); + if (ml) addReason("data-ml", `package.json depends on ${ml}.`); + const qa = hasAny(names, qaDependencies); + if (qa) addReason("qa-testing", `package.json depends on ${qa}.`); + if (manifest.scripts && Object.keys(manifest.scripts).some((name) => /test|lint|typecheck/.test(name))) { + addReason("qa-testing", "package.json declares verification scripts."); + } + } catch { + addReason("repository-intelligence", "package.json exists but could not be parsed; inspect it before selecting packs."); + } + } + + const recommendations = [...reasons.entries()] + .map(([id, packReasons]) => { + const pack = registry.get(id); + return { + id, + displayName: pack.displayName, + description: pack.description, + reasons: packReasons, + confidence: confidenceFor(packReasons.length), + alreadySelected: selected.has(id), + }; + }) + .sort((left, right) => { + const rank = { high: 0, medium: 1, low: 2 }; + return rank[left.confidence] - rank[right.confidence] || left.id.localeCompare(right.id); + }); + + return { schemaVersion: 1, root, recommendations }; +} diff --git a/packages/cli/src/packs/registry.ts b/packages/cli/src/packs/registry.ts index de5e2e7..55e9955 100644 --- a/packages/cli/src/packs/registry.ts +++ b/packages/cli/src/packs/registry.ts @@ -1,26 +1,68 @@ import { CliError } from "../core/errors.js"; -import type { CapabilityPack, TaskRecipe } from "./types.js"; +import type { CapabilityPack, TaskListFilter, TaskRecipe } from "./types.js"; + +export interface ListedTask { + readonly pack: string; + readonly task: TaskRecipe; +} export class PackRegistry { #packs = new Map(); - constructor(packs: readonly CapabilityPack[]) { for (const pack of packs) this.register(pack); } + #tasks = new Map(); + + constructor(packs: readonly CapabilityPack[]) { + for (const pack of packs) this.register(pack); + } + register(pack: CapabilityPack): void { if (this.#packs.has(pack.id)) throw new CliError(`Pack already registered: ${pack.id}`); this.#packs.set(pack.id, pack); + for (const task of pack.tasks) { + const existing = this.#tasks.get(task.id); + if (existing) throw new CliError(`Duplicate task recipe: ${task.id}`, { exitCode: 2, hints: [`Already provided by ${existing.pack}.`] }); + this.#tasks.set(task.id, { pack: pack.id, task }); + } + } + + list(): readonly CapabilityPack[] { + return [...this.#packs.values()]; } - list(): readonly CapabilityPack[] { return [...this.#packs.values()]; } + get(id: string): CapabilityPack { const pack = this.#packs.get(id); if (!pack) throw new CliError(`Unknown capability pack: ${id}`, { exitCode: 2, hints: [`Run mstack pack list to see curated packs.`] }); return pack; } + resolve(ids: readonly string[]): readonly CapabilityPack[] { const resolved = new Map(); - const visit = (id: string) => { const pack = this.get(id); for (const dep of pack.dependencies ?? []) visit(dep); resolved.set(id, pack); }; - ids.forEach(visit); return [...resolved.values()]; + const visit = (id: string) => { + const pack = this.get(id); + for (const dep of pack.dependencies ?? []) visit(dep); + resolved.set(id, pack); + }; + ids.forEach(visit); + return [...resolved.values()]; + } + + tasks(filter: TaskListFilter = {}): readonly ListedTask[] { + if (filter.pack !== undefined) this.get(filter.pack); + return [...this.#tasks.values()].filter((item) => { + if (filter.pack !== undefined && item.pack !== filter.pack) return false; + if (filter.risk !== undefined && item.task.risk !== filter.risk) return false; + return true; + }); } + task(id: string): TaskRecipe { - for (const pack of this.list()) { const task = pack.tasks.find((candidate) => candidate.id === id); if (task) return task; } - throw new CliError(`Unknown task recipe: ${id}`, { exitCode: 2, hints: ["Run mstack task list to see supported recipes."] }); + const listed = this.#tasks.get(id); + if (!listed) throw new CliError(`Unknown task recipe: ${id}`, { exitCode: 2, hints: ["Run mstack task list to see supported recipes."] }); + return listed.task; + } + + taskPack(id: string): string { + const listed = this.#tasks.get(id); + if (!listed) throw new CliError(`Unknown task recipe: ${id}`, { exitCode: 2, hints: ["Run mstack task list to see supported recipes."] }); + return listed.pack; } } diff --git a/packages/cli/src/packs/types.ts b/packages/cli/src/packs/types.ts index f766b58..dc7bfcd 100644 --- a/packages/cli/src/packs/types.ts +++ b/packages/cli/src/packs/types.ts @@ -1,6 +1,12 @@ import type { IntegrationSpec } from "../../../ai-integrations/src/index.js"; -export type TaskRisk = "read-only" | "working-tree" | "destructive" | "remote"; +export const TASK_RISKS = ["read-only", "working-tree", "destructive", "remote"] as const; +export type TaskRisk = (typeof TASK_RISKS)[number]; + +export interface TaskListFilter { + readonly pack?: string; + readonly risk?: TaskRisk; +} export interface TaskRecipe { readonly id: string; diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts index 378f4a5..0518456 100644 --- a/packages/cli/src/program.ts +++ b/packages/cli/src/program.ts @@ -13,11 +13,12 @@ import { explainCommand, statusCommand } from "./commands/status.js"; import { aiListCommand, aiSetupCommand } from "./commands/ai.js"; import { pluginsCommand } from "./commands/plugins.js"; import { catalogCommand, CATALOG_KINDS, type CatalogKind } from "./commands/catalog.js"; -import { packAddCommand, packInfoCommand, packListCommand, packRemoveCommand } from "./commands/packs.js"; +import { packAddCommand, packInfoCommand, packListCommand, packRecommendCommand, packRemoveCommand } from "./commands/packs.js"; import { taskListCommand, taskRunCommand, taskShowCommand } from "./commands/tasks.js"; import { agentCommand } from "./commands/agents.js"; import { validateCommand } from "./commands/validate.js"; import { createDefaultRegistry } from "../../ai-integrations/src/index.js"; +import { TASK_RISKS } from "./packs/types.js"; export interface ProgramOptions { cwd?: string; @@ -57,7 +58,7 @@ export function createProgram(options: ProgramOptions = {}): Command { sortSubcommands: true, sortOptions: true, }) - .addHelpText("after", `\nExamples:\n $ mstack init\n $ mstack ai setup\n $ mstack catalog agents\n $ mstack validate --strict\n $ mstack doctor --json`); + .addHelpText("after", `\nExamples:\n $ mstack init\n $ mstack ai setup\n $ mstack catalog agents\n $ mstack pack recommend\n $ mstack task list --risk read-only\n $ mstack validate --strict\n $ mstack doctor --json`); const globals = (command: Command): { values: ResolvedGlobals; output: Output } => { const raw = command.optsWithGlobals(); @@ -167,17 +168,19 @@ export function createProgram(options: ProgramOptions = {}): Command { program .command("catalog") - .description("discover agents, skills, prompts, hooks, and templates") + .description("discover packs, agents, skills, prompts, hooks, templates, and task recipes") .addArgument(new Argument("[kind]", "limit results to one resource kind").choices([...CATALOG_KINDS])) + .option("--query ", "filter by id, description, or pack") .option("--json", "print a versioned JSON catalog", false) - .action((kind: CatalogKind | undefined, local: { json: boolean }, command: Command) => { + .action((kind: CatalogKind | undefined, local: { json: boolean; query?: string }, command: Command) => { const context = globals(command); - catalogCommand(context.output, kind, local.json); + catalogCommand(context.output, kind, local.json, local.query); }); const pack = program.command("pack").description("discover and manage curated capability packs"); pack.command("list", { isDefault: true }).description("show curated packs").option("--json", "print a versioned JSON result", false).action((local: { json: boolean }, command: Command) => { const context = globals(command); packListCommand(context.output, local.json); }); pack.command("info ").description("show pack resources and prerequisites").option("--json", "print a versioned JSON result", false).action((id: string, local: { json: boolean }, command: Command) => { const context = globals(command); packInfoCommand(context.output, id, local.json); }); + pack.command("recommend").description("suggest curated packs from repository evidence without installing them").option("--json", "print a versioned JSON result", false).action(async (local: { json: boolean }, command: Command) => { const context = globals(command); await packRecommendCommand(context.values.cwd, context.output, local.json); }); pack.command("add ").description("preview and install curated packs into compatible configured runtimes").option("--runtime ", "also configure these runtimes").option("-y, --yes", "accept the displayed plan", false).option("--dry-run", "preview without writing", false).option("--json", "print a versioned JSON result", false).action(async (ids: string[], local: { runtime?: string[]; yes: boolean; dryRun: boolean; json: boolean }, command: Command) => { const context = globals(command); await packAddCommand({ cwd: context.values.cwd, ids, runtimes: local.runtime ?? [], yes: local.yes, dryRun: local.dryRun, json: local.json, output: context.output }); }); pack.command("remove ").description("remove selected packs and reconcile owned runtime files").option("-y, --yes", "accept the displayed reconciliation plan", false).option("--dry-run", "preview without writing", false).option("--json", "print a versioned JSON result", false).action(async (ids: string[], local: { yes: boolean; dryRun: boolean; json: boolean }, command: Command) => { const context = globals(command); await packRemoveCommand({ cwd: context.values.cwd, ids, yes: local.yes, dryRun: local.dryRun, json: local.json, output: context.output }); }); pack.command("update").description("reconcile selected packs through ai setup").option("-y, --yes", "accept the displayed plan", false).option("--dry-run", "preview without writing", false).option("--json", "print a versioned JSON result", false).action(async (local: { yes: boolean; dryRun: boolean; json: boolean }, command: Command) => { const context = globals(command); await packAddCommand({ cwd: context.values.cwd, ids: [], runtimes: [], yes: local.yes, dryRun: local.dryRun, json: local.json, output: context.output }); }); @@ -185,7 +188,7 @@ export function createProgram(options: ProgramOptions = {}): Command { program.command("agent [id]").description("list installed specialists and runtime invocation guidance; does not execute models").option("--json", "print a versioned JSON result", false).action(async (id: string | undefined, local: { json: boolean }, command: Command) => { const context = globals(command); await agentCommand(context.values.cwd, context.output, id, local.json); }); const task = program.command("task").description("inspect and run curated, policy-gated argv-only task recipes"); - task.command("list", { isDefault: true }).description("list task recipes").option("--json", "print a versioned JSON result", false).action((local: { json: boolean }, command: Command) => { const context = globals(command); taskListCommand(context.output, local.json); }); + task.command("list", { isDefault: true }).description("list task recipes").option("--pack ", "limit results to one pack").addOption(new Option("--risk ", "limit results to one risk class").choices([...TASK_RISKS])).option("--json", "print a versioned JSON result", false).action((local: { json: boolean; pack?: string; risk?: typeof TASK_RISKS[number] }, command: Command) => { const context = globals(command); taskListCommand(context.output, local.json, { ...(local.pack === undefined ? {} : { pack: local.pack }), ...(local.risk === undefined ? {} : { risk: local.risk }) }); }); task.command("show ").description("show a task recipe").option("--json", "print a versioned JSON result", false).action((id: string, local: { json: boolean }, command: Command) => { const context = globals(command); taskShowCommand(context.output, id, local.json); }); task.command("run ").description("run one policy-gated task recipe").option("--input ", "provide a declared recipe input").option("-y, --yes", "approve task execution", false).option("--dry-run", "preview argv without executing", false).option("--json", "print a versioned JSON result", false).action(async (id: string, local: { input?: string[]; yes: boolean; dryRun: boolean; json: boolean }, command: Command) => { const context = globals(command); await taskRunCommand({ cwd: context.values.cwd, id, yes: local.yes, dryRun: local.dryRun, json: local.json, ...(local.input === undefined ? {} : { inputs: local.input }), output: context.output }); }); diff --git a/packages/cli/src/services/health.ts b/packages/cli/src/services/health.ts index 8fbb574..cc9e776 100644 --- a/packages/cli/src/services/health.ts +++ b/packages/cli/src/services/health.ts @@ -19,6 +19,7 @@ export interface RepositoryHealth { readonly setup: "not-initialized" | "needs-attention" | "complete"; readonly documents: readonly DocumentHealth[]; readonly integrations: readonly string[]; + readonly packs: readonly string[]; readonly manifest: string | null; readonly next: { command?: string; path?: string; message: string }; } @@ -77,6 +78,7 @@ export async function inspectRepository(start: string): Promise pack.id) ?? [], manifest: manifest ? ".mstack/manifest.json" : null, next, }; diff --git a/packages/cli/test/catalog-validation.test.ts b/packages/cli/test/catalog-validation.test.ts index 6f3fa3d..b816682 100644 --- a/packages/cli/test/catalog-validation.test.ts +++ b/packages/cli/test/catalog-validation.test.ts @@ -4,12 +4,14 @@ import path from "node:path"; import { Writable } from "node:stream"; import { afterEach, describe, expect, it } from "vitest"; import { buildCatalog } from "../src/commands/catalog.js"; +import { createDefaultPackRegistry } from "../src/packs/index.js"; import { hasBlockingDoctorIssue } from "../src/commands/doctor.js"; import { validateRepository } from "../src/commands/validate.js"; import { Output } from "../src/core/output.js"; import { createProgram } from "../src/program.js"; import { readManifest, updateManifest } from "../src/services/manifest.js"; import { makeTemplates } from "./helpers.js"; +import { engineeringAgents, engineeringPrompts, engineeringSkills } from "../../ai-integrations/src/index.js"; const temporary: string[] = []; afterEach(async () => { @@ -33,15 +35,37 @@ async function initializedFixture(): Promise<{ root: string; templates: string } describe("runtime catalog", () => { it("derives complete counts and filtered resources from runtime exports", () => { + const registry = createDefaultPackRegistry(); + const packs = registry.list(); + const packSpecs = packs.map((pack) => pack.createSpec({ projectName: "fixture" })); const complete = buildCatalog(); - expect(complete.counts).toEqual({ packs: 10, agents: 19, skills: 20, prompts: 19, hooks: 4, templates: 10, "task-recipes": 15 }); - expect(complete.items).toHaveLength(97); + expect(complete.counts.packs).toBe(packs.length); + expect(complete.counts["task-recipes"]).toBe(packs.reduce((sum, pack) => sum + pack.tasks.length, 0)); + expect(complete.counts.agents).toBe(engineeringAgents.length + packSpecs.reduce((sum, spec) => sum + (spec.agents?.length ?? 0), 0)); + expect(complete.counts.skills).toBe(engineeringSkills.length + packSpecs.reduce((sum, spec) => sum + (spec.skills?.length ?? 0), 0)); + expect(complete.counts.prompts).toBe(engineeringPrompts.length + packSpecs.reduce((sum, spec) => sum + (spec.prompts?.length ?? 0), 0)); + expect(complete.counts.hooks).toBe(4); + expect(complete.counts.templates).toBe(10); + expect(packs).toHaveLength(14); + expect(complete.counts["task-recipes"]).toBeGreaterThanOrEqual(40); + expect(complete.items.some((item) => item.id === "product-manager" && item.kind === "agents")).toBe(true); + expect(complete.items.some((item) => item.id === "workflow-coordinator" && item.kind === "agents")).toBe(true); const agents = buildCatalog("agents"); - expect(agents.items).toHaveLength(19); expect(agents.items.every((item) => item.kind === "agents")).toBe(true); expect(agents.items.some((item) => item.id === "product-manager")).toBe(true); - expect(agents.items.some((item) => item.id === "workflow-coordinator")).toBe(true); + expect(agents.items.some((item) => item.id === "web-engineer")).toBe(true); + }); + + it("filters the catalog by query without changing source counts", async () => { + const stdout: string[] = []; + const output = new Output({ stdout: memoryStream(stdout), stderr: memoryStream([]), color: false }); + await createProgram({ output }).parseAsync(["node", "mstack", "catalog", "packs", "--query", "mobile", "--json"]); + const report = JSON.parse(stdout.join("")); + expect(report.schemaVersion).toBe(1); + expect(report.query).toBe("mobile"); + expect(report.counts.packs).toBe(14); + expect(report.items.every((item: { id: string; description: string }) => `${item.id} ${item.description}`.toLowerCase().includes("mobile"))).toBe(true); }); it("emits a versioned JSON catalog through the public CLI", async () => { diff --git a/packages/cli/test/packs-tasks.test.ts b/packages/cli/test/packs-tasks.test.ts index 6137611..bd8c633 100644 --- a/packages/cli/test/packs-tasks.test.ts +++ b/packages/cli/test/packs-tasks.test.ts @@ -1,11 +1,15 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { Writable } from "node:stream"; import { afterEach, describe, expect, it } from "vitest"; +import { engineeringAgents, engineeringPrompts, engineeringSkills } from "../../ai-integrations/src/index.js"; import { createDefaultPackRegistry } from "../src/packs/index.js"; +import { recommendPacks } from "../src/packs/recommend.js"; import { createProgram } from "../src/program.js"; import { Output } from "../src/core/output.js"; +import { CliError } from "../src/core/errors.js"; +import { taskRunCommand } from "../src/commands/tasks.js"; const temporary: string[] = []; afterEach(async () => Promise.all(temporary.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))); @@ -14,9 +18,57 @@ function stream(buffer: string[]): NodeJS.WriteStream { return new Writable({ wr describe("capability packs", () => { it("resolves pack dependencies and ships all named starter domains", () => { const registry = createDefaultPackRegistry(); - expect(registry.list()).toHaveLength(10); + expect(registry.list().map((pack) => pack.id)).toEqual([ + "repository-intelligence", + "software-web", + "systems", + "embedded-firmware", + "robotics", + "data-ml", + "mobile", + "games-graphics", + "infrastructure-security", + "cli-devtools", + "backend-api", + "qa-testing", + "observability", + "documentation", + ]); expect(registry.resolve(["robotics"]).map((pack) => pack.id)).toEqual(["systems", "embedded-firmware", "robotics"]); - expect(registry.get("repository-intelligence").createSpec({ projectName: "fixture" }).agents).toHaveLength(5); + expect(registry.get("repository-intelligence").createSpec({ projectName: "fixture" }).agents).toHaveLength(6); + }); + + it("gives every pack specialists, skills, a prompt, and argv-only tasks with unique ids", () => { + const registry = createDefaultPackRegistry(); + const agentIds: string[] = [...engineeringAgents.map((item) => item.id)]; + const skillIds: string[] = [...engineeringSkills.map((item) => item.id)]; + const promptIds: string[] = [...engineeringPrompts.map((item) => item.id)]; + const taskIds: string[] = []; + for (const pack of registry.list()) { + const spec = pack.createSpec({ projectName: "fixture" }); + expect(spec.agents?.length ?? 0).toBeGreaterThan(0); + expect(spec.skills?.length ?? 0).toBeGreaterThan(0); + expect(spec.prompts?.length ?? 0).toBeGreaterThan(0); + expect(pack.tasks.length).toBeGreaterThan(0); + for (const task of pack.tasks) { + expect(task.steps.length).toBeGreaterThan(0); + expect(task.steps.every((step) => step.argv.length > 0)).toBe(true); + expect(taskIds).not.toContain(task.id); + taskIds.push(task.id); + } + for (const agent of spec.agents ?? []) { + expect(agentIds).not.toContain(agent.id); + agentIds.push(agent.id); + } + for (const skill of spec.skills ?? []) { + expect(skillIds).not.toContain(skill.id); + skillIds.push(skill.id); + } + for (const prompt of spec.prompts ?? []) { + expect(promptIds).not.toContain(prompt.id); + promptIds.push(prompt.id); + } + } }); it("prints declarative task dry-run JSON without executing", async () => { @@ -29,7 +81,76 @@ describe("capability packs", () => { it("requires explicit noninteractive approval for a working-tree task", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "mstack-task-")); temporary.push(root); + await writeFile(path.join(root, "package.json"), JSON.stringify({ name: "fixture" })); await expect(createProgram({ cwd: root, output: new Output({ stdout: stream([]), stderr: stream([]), color: false }) }) .parseAsync(["node", "mstack", "task", "run", "dependencies.install", "--json"])).rejects.toThrow("explicit approval"); }); + + it("filters task recipes by pack and risk", async () => { + const stdout: string[] = []; + await createProgram({ output: new Output({ stdout: stream(stdout), stderr: stream([]), color: false }) }) + .parseAsync(["node", "mstack", "task", "list", "--pack", "mobile", "--risk", "read-only", "--json"]); + const report = JSON.parse(stdout.join("")); + expect(report.pack).toBe("mobile"); + expect(report.risk).toBe("read-only"); + expect(report.tasks.length).toBeGreaterThan(0); + expect(report.tasks.every((task: { pack: string; risk: string }) => task.pack === "mobile" && task.risk === "read-only")).toBe(true); + }); + + it("fails before execution when a file precondition is missing", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "mstack-precondition-")); temporary.push(root); + await expect(taskRunCommand({ + cwd: root, + id: "quality.lint", + yes: true, + dryRun: false, + json: false, + output: new Output({ stdout: stream([]), stderr: stream([]), color: false }), + })).rejects.toThrow(/missing precondition package\.json/); + }); + + it("reports a missing executable as a location-specific failure", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "mstack-missing-bin-")); temporary.push(root); + try { + await taskRunCommand({ + cwd: root, + id: "robotics.ros2-doctor", + yes: true, + dryRun: false, + json: false, + output: new Output({ stdout: stream([]), stderr: stream([]), color: false }), + }); + throw new Error("expected missing executable to fail"); + } catch (error) { + expect(error).toBeInstanceOf(CliError); + expect((error as CliError).message).toMatch(/could not find executable ros2/i); + expect((error as CliError).exitCode).toBe(127); + } + }); + + it("recommends packs from repository evidence without installing them", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "mstack-recommend-")); temporary.push(root); + await mkdir(path.join(root, "docs"), { recursive: true }); + await mkdir(path.join(root, "prisma"), { recursive: true }); + await mkdir(path.join(root, ".git")); + await writeFile(path.join(root, "package.json"), JSON.stringify({ + name: "fixture", + bin: { fixture: "cli.js" }, + dependencies: { next: "15.0.0", prisma: "6.0.0" }, + devDependencies: { vitest: "3.0.0" }, + scripts: { test: "vitest" }, + })); + await writeFile(path.join(root, "prisma", "schema.prisma"), "generator client {\n provider = \"prisma-client-js\"\n}\n"); + const report = await recommendPacks(root); + const ids = report.recommendations.map((item) => item.id); + expect(ids).toEqual(expect.arrayContaining([ + "repository-intelligence", + "software-web", + "backend-api", + "qa-testing", + "cli-devtools", + "documentation", + ])); + expect(report.recommendations.every((item) => item.alreadySelected === false)).toBe(true); + }); });