diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b7e8ab..217998b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to RStack are documented here. Entries are user-focused: what you can now do that you couldn't before. +## [1.7.0] - 2026-06-02 + +### Added +- **Read what your agents actually produced.** Open any run and browse its + real deliverables — requirements, architecture, QA report, security review, + release readiness, the plan itself — right in the dashboard, grouped by + stage, with the evidence records beside them. No more digging through + `.rstack/` folders by hand. +- **The dashboard now comes to you in every framework.** It already popped up + on Pi session start; now Operator sessions launch it too, Claude Code + projects get a SessionStart hook from `init`, and any custom harness can + call one command: `npx rstack-agents hub`. + +### Changed +- **README rewritten** — 7× shorter, user-first, and honest: includes a + "what gets recorded (and what doesn't)" section that states plainly that + LLM token usage/cost is not captured until host-side instrumentation lands. + ## [1.6.0] - 2026-06-02 ### Added diff --git a/README.md b/README.md index 7273b9d..ebf9f26 100644 --- a/README.md +++ b/README.md @@ -2,747 +2,148 @@ -**RStack SDLC** is a governed AI software-delivery harness developed by **Richardson Gunde**. - -It gives AI coding agents a repeatable, auditable SDLC with human approval gates, builder/validator contracts, live dashboards, traceability, and memory across runs. +**A governed SDLC for AI coding agents — in any framework.** +RStack gives agent teams a repeatable, auditable 15-stage pipeline with human +approval gates, builder/validator contracts, evidence, episodic memory, and a +live company dashboard. Developed by **Richardson Gunde**. ```text clarify → plan → spec → approve → build → validate → release-readiness → learn ``` -## Copy/paste quick start - -```bash -pi install npm:rstack-agents -``` - -```bash -npx rstack-agents@latest business --project . --port 3008 -``` - -```bash -npx rstack-agents@latest validate && npm test -``` - -## Handy one-line commands - -| Task | Command | -|------|---------| -| Install for Pi | `pi install npm:rstack-agents` | -| Install globally with npm | `npm install -g rstack-agents` | -| Upgrade npm global install | `npm update -g rstack-agents` | -| Use latest without installing | `npx rstack-agents@latest validate` | -| Start Command Center | `rstack-business --project . --port 3008` | -| Start Command Center with npx | `npx rstack-agents@latest business --project . --port 3008` | -| Start dashboard compatibility alias | `rstack-observer --project .` | -| List agents | `rstack-agents list agents` | -| List skills | `rstack-agents list skills` | -| Validate packaged agents | `rstack-agents validate` | -| Run tests from checkout | `npm test` | -| Run release checks | `npm run lint && npm test && npm run validate && npm pack --dry-run` | -| Create release issue | `gh issue create --title "Release rstack-agents v1.0.3" --body "README refresh, Command Center docs, upgrade paths, release checklist"` | -| Create PR | `gh pr create --fill --base main --head docs/readme-command-center-release` | -| Dry-run npm package | `npm pack --dry-run` | - ---- - -## Install - -One command sets up any project for any host framework — Pi, Claude Code, Operator, or custom -(see [docs/integrations/](docs/integrations/README.md)): - -```bash -npx rstack-agents init # auto-detects your framework -npx rstack-agents init -f pi # or be explicit: pi | claude-code | operator | custom -``` - - -Pi, native adapter, recommended - -Pi gets the native RStack adapter, registered `sdlc_*` tools, lifecycle hooks, approval gates, and dashboard auto-launch. - -```bash -pi install npm:rstack-agents -``` - -Install from a local checkout while developing: +## Install — 2 minutes, any framework ```bash -git clone https://github.com/richard-devbot/SDLC-rstack.git -cd SDLC-rstack -npm install -pi install . +cd your-project +npm install rstack-agents +npx rstack-agents init # auto-detects: pi | claude-code | operator | custom ``` -Start a governed run in Pi: - -```text -Use RStack to build a production-ready checkout flow with tests, docs, and release readiness. -``` +| Your framework | What init does | Guide | +|---|---|---| +| **Pi** | Nothing else needed — Pi auto-loads the native extension; `sdlc_*` tools appear in the next session | [docs/integrations/pi.md](docs/integrations/pi.md) | +| **Claude Code** | Writes a usage guide + a SessionStart hook so the dashboard pops up every session; install `/plugin install sdlc-automation` | [docs/integrations/claude-code.md](docs/integrations/claude-code.md) | +| **Operator** | Writes a settings template for the Python adapter (bridges to the same harness) | [docs/integrations/operator.md](docs/integrations/operator.md) | +| **Anything else** | The `.rstack/` state contract + a Node bridge any harness can shell out to | [docs/integrations/custom.md](docs/integrations/custom.md) | -Or call tools directly: +Start a governed run from your agent session: ```text sdlc_start(goal="Build a checkout flow with Stripe, tests, and release readiness") -sdlc_clarify() -sdlc_plan() -sdlc_approve(artifact="plan.md", status="APPROVED") -sdlc_build_next() -sdlc_validate() -sdlc_status() ``` - +## The Business Hub — live observability on :3008 - -Operator, native Python adapter through Node bridge - -Prerequisites: Node.js 18+ and npm on PATH. +The dashboard **launches automatically when a session starts** (Pi, Claude +Code hook, Operator) — or bring it up from any harness: ```bash -npm install -g rstack-agents -pip install operator-use +npx rstack-agents hub ``` -Install package from Python: +What it shows, across **every project on the machine**: -```python -from operator_use.package.installer import install_package -install_package("npm:rstack-agents", get_packages_dir()) -``` +| Page | What you get | +|---|---| +| **Studio / Studio 3D** | Jarvis-style live agent workspace — who's working right now, status as glow; the 3D scene (`/studio3d`) lets you click any agent: *what they worked on, what they shipped, why they're waiting* | +| **Run Analytics** | Wall-clock Gantt per run, stage duration averages, run-over-run trends | +| **Team & Presence** | Who is live now, people directory (runs / approvals / guidance per person), manager rollup per project | +| **Projects & Runs** | Every session; open a run to **read its actual deliverables** — requirements, architecture, QA report, security review — plus evidence records | +| **Approvals / Alerts** | Approve or reject gates from the browser; popups when new gates block | +| **Workflow / Traceability** | 15-stage map with live states; requirement → stage → task → evidence chains | -Install from local checkout: +Switch project/run scope from the top bar; share `#run=` links in Slack. -```python -install_package("/path/to/SDLC-rstack", get_packages_dir()) -``` +## Approvals — no change ships without sign-off -Optional Operator settings: +- Interactive runs gate planning, requirements, and architecture by default +- `.rstack/policy.json` makes chosen stages require approval **in every mode** + (express included): ```json -{ - "extension_list": [ - { - "name": "rstack_sdlc", - "enabled": true, - "settings": { - "worker_command": "pi", - "allow_destructive": "0" - } - } - ] -} -``` - -The Operator adapter reuses the TypeScript harness through `bin/rstack-operator-bridge.ts`. - - - - -Claude Code, asset mode - -Asset mode gives Claude Code the RStack agent, skill, plugin, and governance files. Native tool-call blocking requires a dedicated adapter and is not automatic in asset mode. - -```bash -git clone https://github.com/richard-devbot/SDLC-rstack.git .rstack/vendor/rstack -``` - -Add this to `CLAUDE.md`: - -```markdown -## RStack SDLC - -Use RStack SDLC from `.rstack/vendor/rstack`. -Start with `.rstack/vendor/rstack/agents/core/orchestrator.md`. -Use `.rstack/vendor/rstack/agents/core/builder.md` for implementation. -Use `.rstack/vendor/rstack/agents/core/validator.md` for verification. -Use `.rstack/vendor/rstack/agents/sdlc/` for lifecycle stages. -Write run state under `.rstack/runs//`. -Require builder.json, validation.json, traceability, and command evidence. -Never claim DONE without evidence. -``` - -Then ask Claude: - -```text -Use RStack to plan, build, validate, and document: -``` - - - - -Codex, Gemini, Qwen, Cursor, or any coding agent, asset mode - -Clone RStack into your project: - -```bash -git clone https://github.com/richard-devbot/SDLC-rstack.git .rstack/vendor/rstack -``` - -For Codex or Qwen: - -```bash -cp .rstack/vendor/rstack/docs/public/AGENTS.md.tmpl AGENTS.md -``` - -For Gemini CLI: - -```bash -cp .rstack/vendor/rstack/docs/public/GEMINI.md.tmpl GEMINI.md -``` - -Manual bootstrap for any agent: - -```bash -cat >> AGENTS.md <<'EOF' -# RStack SDLC - -Use RStack SDLC from `.rstack/vendor/rstack`. -Read `agents/core/orchestrator.md` first. -Use `agents/core/builder.md` for implementation tasks. -Use `agents/core/validator.md` for read-only verification. -Use `agents/sdlc/` for lifecycle stages. -Write run state under `.rstack/runs//`. -Require specs, approvals, traceability, builder.json, validation.json, and command evidence. -Never claim DONE without evidence. -EOF -``` - - - - -Local development checkout - -```bash -git clone https://github.com/richard-devbot/SDLC-rstack.git -cd SDLC-rstack -npm install -npm run lint -npm test -npm run validate -npm run business -``` - -Type-check the Pi adapter: - -```bash -npx tsc --noEmit --allowImportingTsExtensions --module NodeNext \ - --moduleResolution NodeNext --target ES2022 --skipLibCheck \ - src/integrations/pi/rstack-sdlc.ts -``` - - - ---- - -## Upgrade existing installs - - -Already using Pi plus RStack SDLC - -Use this when you already installed RStack with `pi install npm:rstack-agents`. - -```bash -pi install npm:rstack-agents@latest -``` - -If your Pi install supports package update commands, this is also safe: - -```bash -pi update rstack-agents || pi install npm:rstack-agents@latest -``` - -Restart Pi after upgrading so extension tools and hooks reload. - -Verify: - -```bash -npx rstack-agents@latest validate -npx rstack-agents@latest business --project . --port 3008 --no-browser -``` - - - - -npm global install - -```bash -npm update -g rstack-agents -``` - -If update does not find the package: - -```bash -npm install -g rstack-agents@latest -``` - -Check the installed binary: - -```bash -rstack-agents validate -rstack-business --project . --port 3008 --no-browser -``` - - - - -npx users - -No permanent upgrade is needed. Always call the latest package: - -```bash -npx rstack-agents@latest validate -npx rstack-agents@latest business --project . --port 3008 -``` - - - - -Local checkout users - -```bash -cd /path/to/SDLC-rstack -git pull --ff-only -npm install -npm run lint -npm test -npm run validate -``` - -If you installed the local checkout into Pi, reinstall it after pulling: - -```bash -pi install . -``` - - - - -Asset-mode integrations, Claude Code, Codex, Gemini, Qwen, Cursor - -If RStack was cloned into `.rstack/vendor/rstack`: - -```bash -cd .rstack/vendor/rstack -git pull --ff-only -npm install -``` - -From the project root, verify the updated assets and dashboard: - -```bash -npx rstack-agents@latest validate -npx rstack-agents@latest business --project . --port 3008 --no-browser -``` - -Restart your host agent so it reloads `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, or any copied prompts. - - - - -Operator users - -```bash -npm install -g rstack-agents@latest -pip install --upgrade operator-use -``` - -If installed from a local package checkout: - -```bash -cd ~/.operator/packages/git/github.com/richard-devbot/SDLC-rstack -git pull --ff-only -npm install -``` - -Restart Operator after upgrade so the Python extension reloads the Node bridge. - - - ---- - -## Dashboard - -RStack ships one local zero-dependency dashboard. It reads `.rstack/runs/` and the global `.rstack` project registry directly and does not require a cloud service. - - -RStack Command Center, primary dashboard, port 3008 - -Use this for business/admin visibility across projects, runs, agents, approvals, guardrails, costs, traceability, and the 15-stage pipeline. - -```bash -rstack-business --project . --port 3008 -``` - -With npx: - -```bash -npx rstack-agents@latest business --project . --port 3008 -``` - -Local development: - -```bash -npm run business -npm run business:dev +{ "required_approvals": { "008-release-readiness": ["release-readiness.json"] } } ``` -Open: +- The moment a gate blocks, **every configured channel is paged** and the + dashboard pops a notification +- Every approval records the real approver (git identity or `RSTACK_USER`) -```text -http://localhost:3008 -``` +## Notifications — Slack, Teams, Discord, Telegram, WhatsApp -Common options: +One event fans out to every channel you configure (env vars or +`.rstack/notifications.json`). Verify in seconds: ```bash -rstack-business --project /path/to/project --port 3008 --no-browser -RSTACK_BUSINESS_PORT=3010 rstack-business --project . -RSTACK_NO_BUSINESS_HUB=1 pi +npx rstack-agents notify --test ``` -`rstack-observer` is kept as a compatibility alias and now opens the same Business Hub. New scripts should use `rstack-business`. - -### Dashboard environment variables +Full setup: [docs/integrations/webhooks.md](docs/integrations/webhooks.md). -| Variable | Default | Purpose | -|----------|---------|---------| -| `RSTACK_BUSINESS_PORT` | `3008` | Command Center port | -| `RSTACK_PROJECT_ROOT` | `cwd` | Project root for the dashboard | -| `RSTACK_NO_BUSINESS_HUB` | `0` | Set to `1` to disable Pi auto-launch | -| `RSTACK_NO_BROWSER` | `0` | Set to `1` to suppress browser open | +## What gets recorded (and what doesn't) ---- +Every run stores its full audit trail under `.rstack/runs//`: +manifest (incl. **who started it**), tasks with builder/validator contracts, +an append-only event stream, evidence records, stage artifacts, approvals, +human guidance (who answered what), stage timings, and checkpoints for +`sdlc_rollback`. The dashboard derives everything from these files — no +database, no telemetry leaves your machine. -## Runtime support - -RStack core stays above host tools. Pi, Operator, Claude Code, Cursor, Codex, Gemini, Qwen, and future tools are integration adapters or asset consumers below the RStack lifecycle. - -| Feature | Pi | Operator | Claude Code | Cursor | Codex / Gemini / Qwen | -|---------|:--:|:--------:|:-----------:|:------:|:---------------------:| -| Native `sdlc_*` tools | ✅ | ✅ | — | — | — | -| Tool-call safety gates | ✅ | ✅ | — | — | — | -| Lifecycle hooks | ✅ | ✅ | — | — | — | -| Human approval blocking | ✅ | ✅ | — | — | — | -| Agents, skills, plugins as assets | ✅ | ✅ | ✅ | ✅ | ✅ | -| Builder and validator contracts | ✅ | ✅ | ✅ | ✅ | ✅ | -| Command Center dashboard | ✅ | ✅ | ✅ | ✅ | ✅ | - -Asset mode means the host agent reads RStack's Markdown/JSON operating assets and writes `.rstack/runs/` state. Native automatic blocking requires a host adapter. - ---- - -## What RStack includes - -```text -196 agents · 156 skills · 36 prompts · 72 plugins -``` - -| Layer | Purpose | -|-------|---------| -| `agents/core/` | Orchestrator, builder, validator team contracts | -| `agents/sdlc/` | 15-stage pipeline from environment to cost estimation | -| `agents/specialists/` | Backend, frontend, devops, QA, security, data, product, docs | -| `skills/` | Reusable workflow instructions | -| `prompts/` | Prompt templates and slash commands | -| `plugins/` | Domain packs with manifests, agents, skills, and commands | -| `src/integrations/pi/rstack-sdlc.ts` | Pi native adapter (`extensions/rstack-sdlc.ts` is a compat shim) | -| `src/integrations/operator/rstack_sdlc.py` | Operator native adapter (`extensions/rstack_sdlc.py` is a compat shim) | -| `bin/rstack-operator-bridge.ts` | Operator Python to Node bridge | -| `src/core/harness/` | SDLC runtime — stages, contracts, evidence, guardrails, run-state | -| `src/core/tracker/` | Project registry and human-in-loop approval queue | -| `src/observability/dashboard/` | Unified RStack Business Hub server and UI on `:3008` | -| `src/observability/collectors/` | Run reporter and legacy dashboard helpers | -| `src/observability/alerts/` | Threshold alerts and plain-language summaries | -| `src/memory/` | Episodic memory, retrieval, and diagnostics | -| `src/notifications/` | Slack / Teams / Discord webhook delivery | - ---- - -## Tool reference, Pi and Operator - -| Tool | Purpose | -|------|---------| -| `sdlc_orchestrate` | Load orchestrator, builder, and validator instructions | -| `sdlc_start` | Create `.rstack/runs//` state for a new run | -| `sdlc_clarify` | Capture product-owner answers before planning | -| `sdlc_plan` | Create lifecycle tasks, draft specs, routing metadata, traceability | -| `sdlc_spec` | Read or update spec artifacts | -| `sdlc_approve` | Record human approval or rejection gates | -| `sdlc_agents` | List agents, skills, and plugins by domain | -| `sdlc_delegate` | Spawn isolated worker agents | -| `sdlc_build_next` | Prepare the next gated builder task packet | -| `sdlc_validate` | Validate builder output and write `validation.json` | -| `sdlc_status` | Show run status, tasks, approvals, next action | -| `sdlc_memory` | Search or append project learnings | -| `sdlc_dashboard` | Generate a static dashboard for a run | -| `sdlc_trace` | Show a CLI trace for one task or run | -| `sdlc_rollback` | Roll back an SDLC stage checkpoint | - -Pi slash commands: - -```text -/sdlc Start a governed SDLC run -/sdlc-agents Browse available agents, skills, and plugins -``` - ---- +**Honest limitation:** LLM token usage and real cost are not captured — +the host framework executes the model calls, so the harness never sees usage. +Cost shows $0 unless a builder reports it in its contract. Host-side usage +instrumentation is on the roadmap. ## CLI reference -```bash -rstack-agents list agents -rstack-agents list skills -rstack-agents list plugins -rstack-agents validate -rstack-agents add plugin backend-development -rstack-business [--port 3008] [--project ] [--no-browser] -rstack-observer [--project ] [--no-browser] # compatibility alias for rstack-business -``` - -Package scripts from a checkout: - -```bash -npm run lint -npm test -npm run validate -npm run business -npm run business:dev -npm run observer -npm run observer:dev -npm run build:all -``` - ---- - -## 15-stage SDLC pipeline - -```text -00-environment Scan tools, versions, project structure -01-transcript Parse meeting notes into structured requirements -02-requirements Extract functional and non-functional requirements -03-documentation Generate BRD, FRD, SOW -04-planning Sprint plan, timeline, team composition -05-jira Epic, Story, Task hierarchy -06-architecture HLD, API contracts, DB schema -07-code Production-ready code scaffolding -08-testing Test plan, cases, API tests, security checklist -09-deployment Dockerfiles, CI/CD pipelines, IaC -10-summary Executive dashboard, artifact inventory -11-feedback-loop Cross-pipeline consistency review -12-security-threat-model STRIDE and OWASP Top 10 -13-compliance-checker HIPAA, GDPR, PCI-DSS, SOC 2 gap analysis -14-cost-estimation Cloud cost forecast for AWS, Azure, GCP -``` - ---- - -## Governance model - -RStack enforces this sequence: - -```text -clarify → plan → spec → approve → build → validate → release-readiness → memory -``` - -Controls: +| Command | Purpose | +|---|---| +| `rstack-agents init [-f framework]` | Set up RStack in a project (idempotent) | +| `rstack-agents hub` | Ensure the dashboard is running and open it | +| `rstack-agents notify [--test]` | Inspect / test notification channels | +| `rstack-agents list agents\|skills\|plugins` | Browse the packaged catalog (196 agents) | +| `rstack-agents add plugin ` | Copy a packaged plugin into the project | +| `rstack-agents validate` | Validate packaged agent definitions | +| `rstack-business [--port N] [--project path]` | Run the dashboard directly | -- No build before plan approval in interactive mode -- Destructive actions require explicit approval -- Validators default to read-only tools -- Every task requires acceptance criteria, `builder.json`, and `validation.json` -- Traceability is written to run artifacts -- No DONE without command evidence +### Environment -Protected actions blocked unless approved: +| Variable | Purpose | +|---|---| +| `RSTACK_USER` / `RSTACK_USER_EMAIL` | Identity for runs/approvals (defaults to git config) | +| `RSTACK_BUSINESS_PORT` | Dashboard port (default 3008) | +| `RSTACK_NO_BUSINESS_HUB=1` | Disable dashboard auto-launch | +| `RSTACK_SLACK_WEBHOOK` etc. | Notification channels — see webhooks guide | +| `RSTACK_DEFAULT_MODEL` / `RSTACK_ESCALATED_MODEL` | Models for delegated builders (escalation at attempt ≥ 2) | -```text -rm -rf git push --force npm publish -terraform apply/destroy kubectl apply/delete -helm install/upgrade/uninstall DROP TABLE / DELETE FROM -``` - -Protected write paths blocked unless approved: - -```text -.env .env.* id_rsa id_ed25519 credentials.* secrets.* .npmrc .pypirc -``` - -To unblock a destructive action in a governed run: - -```text -sdlc_approve(artifact="destructive-action", status="APPROVED") -``` - ---- - -## Generated run state - -```text -.rstack/ - runs/ - / - manifest.json - events.jsonl - metrics.json - tasks.json - approvals.jsonl - traceability.json - specs/ - product-brief.md - requirements.json - architecture.md - implementation-report.json - qa-report.json - security-review.md - handoff.md - release-readiness.json - artifacts/ - stages/ - 02-requirements/ - 06-architecture/ - 07-code/ - 08-testing/ - tasks/ - / - prompt.md - builder.json - validation.json -``` - -Project memory: +## Architecture -```text -.rstack/memory/ - episodes.jsonl - facts.jsonl - retractions.jsonl - retrieval-events.jsonl -``` - ---- - -## Notifications - -```bash -export RSTACK_SLACK_WEBHOOK="https://hooks.slack.com/services/..." -export RSTACK_DISCORD_WEBHOOK="https://discord.com/api/webhooks/..." -export RSTACK_TEAMS_WEBHOOK="https://outlook.office.com/webhook/..." -``` - -Alert defaults: - -| Threshold | Default | -|-----------|---------| -| Cost per run | `$0.50` | -| Daily total cost | `$5.00` | -| Guardrail hit rate | `20% of tasks` | -| Task failure rate | `30% of tasks` | -| Stalled run | `30 min without events` | -| Pending approvals | `>= 1` | - ---- - -## Maintainer release flow - -These commands are for maintainers. Do not publish until tests pass, `npm pack --dry-run` looks right, npm auth is confirmed, and the release version is approved. - - -Recommended issue and branch - -```bash -gh issue create \ - --title "Release rstack-agents v1.0.3" \ - --body "README install dropdowns, Command Center docs, upgrade commands, and release checklist." ``` - -```bash -git checkout -b docs/readme-command-center-release +src/ +├── core/ harness (stages, contracts, evidence, guardrails, +│ run-state, identity) + tracker (registry, approvals) +├── observability/ collectors · Business Hub dashboard · alerts · metrics +├── integrations/ pi/ · operator/ · init (claude-code + custom) +├── notifications/ channels (slack, teams, discord, telegram, whatsapp) + router +└── memory/ episodic memory, retrieval, diagnostics ``` - +Layer rules and the full map: [src/README.md](src/README.md). +Adapter contract for new frameworks: [docs/integrations/custom.md](docs/integrations/custom.md). +Harness internals: [docs/HARNESS.md](docs/HARNESS.md). - -Validation before PR +## Development ```bash +git clone https://github.com/richard-devbot/SDLC-rstack.git +cd SDLC-rstack && npm install +npm test # 100+ tests npm run lint -npm test -npm run validate -npm pack --dry-run -``` - -One line: - -```bash -npm run lint && npm test && npm run validate && npm pack --dry-run -``` - - - - -Create PR - -```bash -git status --short -git add -git commit -m "docs: refresh install and release guidance" -git push -u origin docs/readme-command-center-release -gh pr create --fill --base main --head docs/readme-command-center-release -``` - -Adjust the `git add` list if your working tree has different files. - - - - -Version and npm publish, approval required - -Recommended semver for this README plus dashboard polish release: patch bump from `1.0.2` to `1.0.3`. - -```bash -npm version patch --no-git-tag-version -npm run lint && npm test && npm run validate && npm pack --dry-run -npm whoami -npm publish --access public -``` - -If `npm whoami` fails: - -```bash -npm login -npm whoami -``` - -After publish: - -```bash -npm view rstack-agents version -pi install npm:rstack-agents@latest -npx rstack-agents@latest validate +npm run validate # 196 packaged agents ``` - - ---- - -## Adapter roadmap - -| Status | Adapter | -|--------|---------| -| ✅ Shipped | Pi, native TypeScript adapter with full hooks | -| ✅ Shipped | Operator, native Python adapter through Node bridge | -| 🔜 Next | MCP, expose `sdlc_*` tools to any MCP client | -| 🔜 Next | Claude Code, native adapter with tool-call hooks | -| 📋 Planned | SDK, Node/Python library for custom harnesses | -| 📋 Planned | Codex, Gemini, Qwen, generated config packs | - -The RStack Command Center works with all runtimes today because it reads `.rstack/runs/` directly. - ---- +Changes follow issues-first: every PR closes a GitHub issue. See +[CHANGELOG.md](CHANGELOG.md) for what each release lets you do. ## License -MIT, developed by Richardson Gunde. - -Repository: [github.com/richard-devbot/SDLC-rstack](https://github.com/richard-devbot/SDLC-rstack) +MIT © Richardson Gunde diff --git a/bin/rstack-agents.js b/bin/rstack-agents.js index 4095c1a..46c57ea 100644 --- a/bin/rstack-agents.js +++ b/bin/rstack-agents.js @@ -15,6 +15,8 @@ import { listAgents, listSkills, listPlugins, addPlugin } from '../src/commands/ import { validateCommand } from '../src/commands/validate.js'; import { initFramework, detectFramework, FRAMEWORKS } from '../src/integrations/init.js'; import { notifyAll, resolveChannels, formatSlackStageMessage } from '../src/notifications/index.js'; +import { autoLaunchBusinessHub } from '../src/hooks/auto-launch.js'; +import { registerProject } from '../src/core/tracker/registry.js'; import { log } from '../src/utils/logger.js'; import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; @@ -113,6 +115,22 @@ program } }); +program + .command('hub') + .description('Ensure the Business Hub is running on :3008 and open it — the universal session-start hook for any framework') + .option('-p, --project ', 'project root to register (defaults to current directory)') + .option('--no-browser', 'do not open the browser') + .action(async (opts) => { + try { + const projectRoot = resolve(opts.project ?? process.cwd()); + await registerProject(projectRoot); + await autoLaunchBusinessHub(projectRoot, { noBrowser: opts.browser === false }); + } catch (err) { + log.error(err.message); + process.exit(1); + } + }); + program .command('notify') .description('Inspect configured notification channels; --test sends a test message to all of them') diff --git a/package-lock.json b/package-lock.json index b5114cf..d15c5fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rstack-agents", - "version": "1.6.0", + "version": "1.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rstack-agents", - "version": "1.6.0", + "version": "1.7.0", "license": "MIT", "dependencies": { "@earendil-works/pi-ai": "^0.74.1", diff --git a/package.json b/package.json index 8a528b3..c794060 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rstack-agents", - "version": "1.6.0", + "version": "1.7.0", "description": "Production-ready agentic SDLC framework for Pi and coding agents — orchestrator, builder/validator teams, lifecycle state, and specialist reuse", "type": "module", "main": "src/index.js", diff --git a/src/core/tracker/approvals.js b/src/core/tracker/approvals.js index 9ae91bb..428495f 100644 --- a/src/core/tracker/approvals.js +++ b/src/core/tracker/approvals.js @@ -10,13 +10,92 @@ function queuePath(projectRoot) { return join(projectRoot, QUEUE_FILE); } -export async function appendApproval(projectRoot, entry) { +function runApprovalsPath(projectRoot, runId) { + return join(projectRoot, '.rstack', 'runs', runId, 'approvals.json'); +} + +function policyPath(projectRoot) { + return join(projectRoot, '.rstack', 'policy.json'); +} + +function encodePart(value) { + return encodeURIComponent(String(value ?? '')); +} + +function decodePart(value) { + try { return decodeURIComponent(value ?? ''); } catch { return value ?? ''; } +} + +export function approvalQueueId({ runId, taskId, artifact }) { + return `gate:${encodePart(runId)}:${encodePart(taskId ?? '')}:${encodePart(artifact)}`; +} + +export function parseApprovalQueueId(id) { + if (typeof id !== 'string' || !id.startsWith('gate:')) return null; + const [, runId, taskId, artifact] = id.split(':'); + if (!runId || !artifact) return null; + return { runId: decodePart(runId), taskId: decodePart(taskId), artifact: decodePart(artifact) }; +} + +async function readJson(path, fallback) { + if (!existsSync(path)) return fallback; + try { return JSON.parse(await readFile(path, 'utf8')); } catch { return fallback; } +} + +async function writeQueue(projectRoot, approvals) { await mkdir(join(projectRoot, '.rstack'), { recursive: true }); - const line = JSON.stringify({ ...entry, ts: new Date().toISOString() }) + '\n'; const path = queuePath(projectRoot); - try { - await writeFile(path, line, { flag: 'a' }); - } catch { /* best-effort */ } + const lines = approvals.map((approval) => JSON.stringify(approval)).join('\n'); + await writeFile(path, lines ? `${lines}\n` : ''); +} + +export async function readApprovalPolicy(projectRoot) { + const policy = await readJson(policyPath(projectRoot), {}); + return policy && typeof policy === 'object' ? policy : {}; +} + +export function configuredManagers(policy = {}, env = process.env) { + const fromPolicy = [ + ...(Array.isArray(policy.managers) ? policy.managers : []), + ...(Array.isArray(policy.manager_users) ? policy.manager_users : []), + ...(Array.isArray(policy.manager_allowlist) ? policy.manager_allowlist : []), + ]; + const fromEnv = (env.RSTACK_MANAGER_USERS || env.RSTACK_MANAGERS || '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + return [...new Set([...fromPolicy, ...fromEnv].map((item) => String(item).trim()).filter(Boolean))]; +} + +export async function assertManagerAllowed(projectRoot, resolvedBy, env = process.env) { + const managers = configuredManagers(await readApprovalPolicy(projectRoot), env); + if (!managers.length) return true; + const actor = String(resolvedBy ?? '').trim().toLowerCase(); + const allowed = managers.map((item) => String(item).trim().toLowerCase()); + if (actor && allowed.includes(actor)) return true; + const err = new Error(`approval by ${resolvedBy || 'unknown'} is not allowed by manager policy`); + err.statusCode = 403; + throw err; +} + +export async function appendApproval(projectRoot, entry) { + const all = await readApprovals(projectRoot); + const now = new Date().toISOString(); + const id = entry.id || approvalQueueId(entry); + const existing = all.findIndex((approval) => approval.id === id); + const next = { + status: 'pending', + ...entry, + id, + ts: entry.ts ?? now, + updatedAt: now, + }; + + if (existing === -1) all.push(next); + else all[existing] = { ...all[existing], ...next }; + + await writeQueue(projectRoot, all); + return next; } export async function readApprovals(projectRoot) { @@ -30,16 +109,83 @@ export async function readApprovals(projectRoot) { } catch { return []; } } -export async function resolveApproval(projectRoot, id, decision, resolvedBy) { +export async function appendRunApproval(projectRoot, runId, record) { + if (!runId || !record?.artifact) return null; + const path = runApprovalsPath(projectRoot, runId); + await mkdir(join(projectRoot, '.rstack', 'runs', runId), { recursive: true }); + const approvals = await readJson(path, []); + const all = Array.isArray(approvals) ? approvals : []; + const next = { + id: record.id || `app-${new Date().toISOString().replace(/[:.]/g, '-')}`, + artifact: record.artifact, + status: record.status, + approver: record.approver, + timestamp: record.timestamp || new Date().toISOString(), + comments: record.comments, + source: record.source || 'dashboard', + }; + all.push(next); + await writeFile(path, JSON.stringify(all, null, 2)); + return next; +} + +export async function resolveApproval(projectRoot, id, decision, resolvedBy, options = {}) { const all = await readApprovals(projectRoot); const idx = all.findIndex(a => a.id === id); - if (idx === -1) return false; - all[idx] = { ...all[idx], status: decision, resolvedBy, resolvedAt: new Date().toISOString() }; - const path = queuePath(projectRoot); - await writeFile(path, all.map(a => JSON.stringify(a)).join('\n') + '\n'); + const parsed = idx === -1 ? parseApprovalQueueId(id) : null; + if (idx === -1 && !parsed) return false; + if (idx === -1 && parsed && !existsSync(join(projectRoot, '.rstack', 'runs', parsed.runId))) return false; + + const base = idx === -1 ? { + id, + ...parsed, + title: `Approve ${parsed.artifact}`, + detail: parsed.taskId ? `Task ${parsed.taskId} is blocked` : 'Workflow is blocked', + status: 'pending', + source: 'blocked_gate', + ts: new Date().toISOString(), + } : all[idx]; + + const approver = resolvedBy || 'dashboard'; + await assertManagerAllowed(projectRoot, approver, options.env ?? process.env); + + const queueStatus = decision === 'approved' ? 'approved' : 'rejected'; + const runStatus = decision === 'approved' ? 'APPROVED' : 'REJECTED'; + const resolvedAt = new Date().toISOString(); + const next = { ...base, status: queueStatus, resolvedBy: approver, resolvedAt, updatedAt: resolvedAt }; + + if (idx === -1) all.push(next); + else all[idx] = next; + await writeQueue(projectRoot, all); + + if (!options.skipRunWrite && base.runId && base.artifact) { + await appendRunApproval(projectRoot, base.runId, { + id: `dash-${resolvedAt.replace(/[:.]/g, '-')}`, + artifact: base.artifact, + status: runStatus, + approver, + timestamp: resolvedAt, + comments: base.taskId ? `Dashboard ${queueStatus} for blocked task ${base.taskId}` : `Dashboard ${queueStatus}`, + source: 'business-hub', + }); + } + return true; } +export async function resolveQueuedApprovalForArtifact(projectRoot, { runId, taskId, artifact, decision, resolvedBy, skipRunWrite = true }) { + const all = await readApprovals(projectRoot); + const match = all.find((approval) => + approval.runId === runId && + approval.artifact === artifact && + (taskId ? approval.taskId === taskId : true) && + (!approval.status || approval.status === 'pending') + ); + const id = match?.id || approvalQueueId({ runId, taskId, artifact }); + if (!match && !all.some((approval) => approval.id === id)) return false; + return resolveApproval(projectRoot, id, decision, resolvedBy, { skipRunWrite }); +} + export function pendingApprovals(approvals) { return approvals.filter(a => !a.status || a.status === 'pending'); } diff --git a/src/hooks/auto-launch.js b/src/hooks/auto-launch.js index b9d9a35..922e0e7 100644 --- a/src/hooks/auto-launch.js +++ b/src/hooks/auto-launch.js @@ -24,8 +24,9 @@ export async function autoLaunchBusinessHub(projectRoot, opts = {}) { const port = Number(process.env.RSTACK_BUSINESS_PORT ?? 3008); const already = await isPortOpen(port); if (already) { - // Already running — just print the URL so the user can click + // Already running — pop the browser so the session starts with the hub visible. console.log(` \x1b[2mRStack Business Hub: http://localhost:${port}\x1b[0m`); + if (!opts.noBrowser && process.env.RSTACK_NO_BROWSER !== '1') openBrowser(`http://localhost:${port}`); return; } @@ -45,11 +46,13 @@ export async function autoLaunchBusinessHub(projectRoot, opts = {}) { const url = `http://localhost:${port}`; console.log(` \x1b[33mRStack Business Hub launched: ${url}\x1b[0m`); - if (!opts.noBrowser && process.env.RSTACK_NO_BROWSER !== '1') { - const cmd = process.platform === 'win32' ? 'start' - : process.platform === 'darwin' ? 'open' : 'xdg-open'; - try { - spawn(cmd, [url], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' }).unref(); - } catch { /* best-effort */ } - } + if (!opts.noBrowser && process.env.RSTACK_NO_BROWSER !== '1') openBrowser(url); +} + +function openBrowser(url) { + const cmd = process.platform === 'win32' ? 'start' + : process.platform === 'darwin' ? 'open' : 'xdg-open'; + try { + spawn(cmd, [url], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' }).unref(); + } catch { /* best-effort */ } } diff --git a/src/integrations/init.js b/src/integrations/init.js index 98dd4ad..1603198 100644 --- a/src/integrations/init.js +++ b/src/integrations/init.js @@ -87,10 +87,23 @@ export async function initFramework(projectRoot, framework, { packageRoot } = {} if (fw === 'claude-code') { const docPath = join(root, '.claude', 'rstack-sdlc.md'); await writeIfMissing(docPath, CLAUDE_CODE_DOC, '.claude/rstack-sdlc.md', report); + // Auto-launch the Business Hub on every Claude Code session. We only + // create settings.json when it doesn't exist — never rewrite the user's. + const settingsPath = join(root, '.claude', 'settings.json'); + const hookSettings = JSON.stringify({ + hooks: { + SessionStart: [{ hooks: [{ type: 'command', command: 'npx -y rstack-agents hub' }] }], + }, + }, null, 2) + '\n'; + const wroteSettings = await writeIfMissing(settingsPath, hookSettings, '.claude/settings.json (SessionStart → Business Hub auto-launch)', report); + if (!wroteSettings) { + await writeIfMissing(join(root, '.claude', 'rstack-hub-hook.json'), hookSettings, '.claude/rstack-hub-hook.json (merge into your settings.json hooks)', report); + report.nextSteps.push('Your .claude/settings.json already exists — merge the SessionStart hook from .claude/rstack-hub-hook.json so the dashboard pops up each session.'); + } report.nextSteps.push( 'Install the Claude Code plugin: /plugin install sdlc-automation (or add the marketplace repo)', 'Run /sdlc-start in Claude Code to drive the full pipeline', - 'Open the dashboard: npx rstack-business', + 'The Business Hub auto-opens each session (SessionStart hook) — or run: npx rstack-agents hub', ); } @@ -123,7 +136,7 @@ export async function initFramework(projectRoot, framework, { packageRoot } = {} 'RStack state lives in .rstack/ — any agent framework that writes the run contract can plug in.', 'Adapter contract: read docs/integrations/custom.md in the rstack-agents package.', 'Reuse the Node bridge for tool calls: npx tsx node_modules/rstack-agents/bin/rstack-operator-bridge.ts \'\'', - 'Open the dashboard: npx rstack-business', + 'Auto-launch the dashboard from your harness session hook: npx rstack-agents hub', ); } diff --git a/src/integrations/operator/rstack_sdlc.py b/src/integrations/operator/rstack_sdlc.py index b7078d0..abf4427 100644 --- a/src/integrations/operator/rstack_sdlc.py +++ b/src/integrations/operator/rstack_sdlc.py @@ -46,6 +46,48 @@ } +def _launch_business_hub() -> None: + """Bring the Business Hub live when an Operator session loads this extension. + + Same contract as the Pi adapter: health-check :3008, spawn detached if + down, open the browser. Best-effort — never blocks or fails the session. + Opt out with RSTACK_NO_BUSINESS_HUB=1. + """ + if os.environ.get("RSTACK_NO_BUSINESS_HUB") == "1" or os.environ.get("CI"): + return + import subprocess + import urllib.request + import webbrowser + + port = int(os.environ.get("RSTACK_BUSINESS_PORT", "3008")) + url = f"http://localhost:{port}" + alive = False + try: + with urllib.request.urlopen(f"{url}/health", timeout=0.7) as response: + alive = json.loads(response.read().decode("utf8")).get("ok") is True + except Exception: + alive = False + + try: + if not alive: + node = shutil.which("node") + hub_bin = PKG_ROOT / "bin" / "rstack-business.js" + if not node or not hub_bin.exists(): + return + subprocess.Popen( + [node, str(hub_bin), "--no-browser", "--project", os.getcwd()], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True, + env={**os.environ, "RSTACK_NO_BROWSER": "1", "RSTACK_BUSINESS_PORT": str(port)}, + ) + webbrowser.open(url) + except Exception: + pass # the dashboard is a companion, never a blocker + + +_launch_business_hub() + + # ── Parameter models (mirror the typebox schemas in rstack-sdlc.ts) ──────────── class OrchestrateParams(BaseModel): diff --git a/src/integrations/pi/rstack-sdlc.ts b/src/integrations/pi/rstack-sdlc.ts index 480097d..aad0bd8 100644 --- a/src/integrations/pi/rstack-sdlc.ts +++ b/src/integrations/pi/rstack-sdlc.ts @@ -15,6 +15,7 @@ import { appendEvidenceEvent } from "../../core/harness/evidence.js"; import { DEFAULT_HARNESS_GUARDRAILS, guardrailSummary } from "../../core/harness/guardrails.js"; import { prepareRunState, prepareStageFolders, createStageCheckpoint, rollbackStage, updateRunMetrics } from "../../core/harness/run-state.js"; import { resolveUserIdentity } from "../../core/harness/identity.js"; +import { appendApproval as appendApprovalRequest, approvalQueueId, assertManagerAllowed, resolveQueuedApprovalForArtifact } from "../../core/tracker/approvals.js"; import { appendEpisode, appendLearning, episodeFromValidation, formatEpisodesForPrompt, projectMemoryDir, readMemoryConfig, recallEpisodes, sanitizeMemoryText, searchLearnings, writeRetrievalEvent } from "../../memory/index.js"; import { buildRunReport, generateRunReport, renderDashboardHtml, renderTraceHtml } from "../../observability/collectors/reporter.js"; import { notifyAll, hasConfiguredChannels, formatSlackStageMessage, formatSlackTaskReportMessage } from "../../notifications/index.js"; @@ -1135,11 +1136,14 @@ export default function (pi: ExtensionAPI) { } catch {} } + const approver = params.approver || resolveUserIdentity(projectRoot).name; + await assertManagerAllowed(projectRoot, approver); + const record: ApprovalRecord = { id: `app-${timestamp().replace(/[:.]/g, "-")}`, artifact: params.artifact, status: params.status, - approver: params.approver || resolveUserIdentity(projectRoot).name, + approver, timestamp: timestamp(), comments: params.comments }; @@ -1148,6 +1152,12 @@ export default function (pi: ExtensionAPI) { await writeFile(path, JSON.stringify(approvals, null, 2)); await appendEvent(projectRoot, manifest.run_id, { type: "approval_gate", artifact: params.artifact, status: params.status }); await addTrace(projectRoot, manifest.run_id, { type: "approval", ...record }); + await resolveQueuedApprovalForArtifact(projectRoot, { + runId: manifest.run_id, + artifact: params.artifact, + decision: params.status === "APPROVED" ? "approved" : "rejected", + resolvedBy: record.approver, + }); try { const payload = formatSlackStageMessage(manifest.run_id, params.artifact, params.status === "APPROVED" ? "PASS" : "BLOCKED", { @@ -1336,6 +1346,21 @@ export default function (pi: ExtensionAPI) { const missing = missingApprovals(await readApprovals(runDir), requiredApprovals); if (missing.length) { await appendEvent(projectRoot, manifest.run_id, { type: "approval_gate_blocked", task_id: task.id, missing }); + const requestedBy = manifest.started_by?.name ?? resolveUserIdentity(projectRoot).name; + for (const artifact of missing) { + await appendApprovalRequest(projectRoot, { + id: approvalQueueId({ runId: manifest.run_id, taskId: task.id, artifact }), + title: `Approve ${artifact}`, + detail: `Task ${task.id} is blocked until ${artifact} is approved`, + status: "pending", + runId: manifest.run_id, + taskId: task.id, + artifact, + requestedBy, + projectRoot, + source: "approval_gate_blocked", + }); + } // Page the manager the moment a gate blocks — silence here meant // blocked work waited until someone happened to open the dashboard. try { diff --git a/src/observability/dashboard/server.js b/src/observability/dashboard/server.js index 09bc2fe..c7aaa41 100644 --- a/src/observability/dashboard/server.js +++ b/src/observability/dashboard/server.js @@ -1,10 +1,13 @@ import { createServer } from 'node:http'; import { spawn } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { resolve } from 'node:path'; +import { join, resolve, sep } from 'node:path'; +import { existsSync } from 'node:fs'; +import { readFile, stat } from 'node:fs/promises'; import { dashboardHtml } from './ui.js'; import { studio3dHtml } from './ui/studio3d.js'; import { buildFullState, resolveDashboardApproval, toClientState } from './state/index.js'; +import { sourceRoots } from './state/roots.js'; // owner: RStack developed by Richardson Gunde @@ -135,13 +138,52 @@ async function handleApproval(req, res, decision) { } catch (err) { process.stderr.write(`[rstack-business] approval error: ${err?.message}\n`); if (!res.headersSent) { - res.writeHead(500, { 'Content-Type': 'application/json' }); + const statusCode = Number(err?.statusCode) || 500; + res.writeHead(statusCode, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: String(err?.message) })); } } }); } +// Read one run artifact, strictly sandboxed: the run is located via the known +// project roots, the resolved path must stay inside that run directory, and +// only size-capped text artifacts are served. +const ARTIFACT_MAX_BYTES = 512 * 1024; +const ARTIFACT_EXTENSIONS = new Set(['.md', '.json', '.jsonl', '.txt', '.yml', '.yaml']); + +async function handleArtifact(url, res) { + const sendJson = (status, body) => { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); + }; + try { + const runId = url.searchParams.get('run') ?? ''; + const relPath = url.searchParams.get('path') ?? ''; + if (!runId || !relPath) return sendJson(400, { error: 'run and path are required' }); + if (runId.includes('/') || runId.includes('..')) return sendJson(400, { error: 'invalid run id' }); + + const roots = await sourceRoots(PROJECT_ROOT, {}); + const runDir = roots + .map((root) => join(root, '.rstack', 'runs', runId)) + .find((dir) => existsSync(dir)); + if (!runDir) return sendJson(404, { error: 'run not found' }); + + const target = resolve(runDir, relPath); + if (target !== runDir && !target.startsWith(runDir + sep)) return sendJson(403, { error: 'path escapes the run directory' }); + const extension = target.slice(target.lastIndexOf('.')).toLowerCase(); + if (!ARTIFACT_EXTENSIONS.has(extension)) return sendJson(415, { error: 'only text artifacts are served' }); + const info = await stat(target).catch(() => null); + if (!info?.isFile()) return sendJson(404, { error: 'artifact not found' }); + if (info.size > ARTIFACT_MAX_BYTES) return sendJson(413, { error: `artifact exceeds ${ARTIFACT_MAX_BYTES} bytes` }); + + const content = await readFile(target, 'utf8'); + sendJson(200, { run: runId, path: relPath, size: info.size, content }); + } catch (err) { + sendJson(500, { error: String(err?.message) }); + } +} + const server = createServer(async (req, res) => { const url = new URL(req.url, `http://localhost:${PORT}`); @@ -194,6 +236,11 @@ const server = createServer(async (req, res) => { return; } + if (url.pathname === '/api/artifact' && req.method === 'GET') { + await handleArtifact(url, res); + return; + } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(dashboardHtml(PORT)); }); diff --git a/src/observability/dashboard/state/approvals.js b/src/observability/dashboard/state/approvals.js index cbf45fb..efb86ee 100644 --- a/src/observability/dashboard/state/approvals.js +++ b/src/observability/dashboard/state/approvals.js @@ -1,11 +1,17 @@ -import { readApprovals, pendingApprovals, approvalSummary, resolveApproval } from '../../../core/tracker/approvals.js'; +import { + readApprovals, + pendingApprovals, + approvalSummary, + resolveApproval, + approvalQueueId, +} from '../../../core/tracker/approvals.js'; // owner: RStack developed by Richardson Gunde export async function getAllApprovals(roots) { const perRoot = await Promise.all((roots ?? []).map(async (root) => { const approvals = await readApprovals(root); - return approvals.map((approval) => ({ ...approval, projectRoot: approval.projectRoot ?? root, source: 'queue' })); + return approvals.map((approval) => ({ ...approval, projectRoot: approval.projectRoot ?? root, source: approval.source ?? 'queue' })); })); return perRoot.flat().sort((a, b) => (b.ts ?? '').localeCompare(a.ts ?? '')); } @@ -28,6 +34,34 @@ export function buildBlockedGates(runs) { .sort((a, b) => (b.ts ?? '').localeCompare(a.ts ?? '')); } +export function approvalRequestsFromBlockedGates(blockedGates, queueApprovals = []) { + const existing = new Set((queueApprovals ?? []).map((approval) => approval.id)); + const requests = []; + + for (const gate of blockedGates ?? []) { + const missing = gate.missing?.length ? gate.missing : ['manager-approval']; + for (const artifact of missing) { + const id = approvalQueueId({ runId: gate.runId, taskId: gate.taskId, artifact }); + if (existing.has(id)) continue; + existing.add(id); + requests.push({ + id, + title: `Approve ${artifact}`, + detail: gate.detail, + status: 'pending', + runId: gate.runId, + taskId: gate.taskId, + artifact, + projectRoot: gate.projectRoot, + source: 'blocked_gate', + ts: gate.ts, + }); + } + } + + return requests; +} + export function summarizeApprovals(queueApprovals) { const pending = pendingApprovals(queueApprovals); return { diff --git a/src/observability/dashboard/state/client-state.js b/src/observability/dashboard/state/client-state.js index 819d110..ffa3882 100644 --- a/src/observability/dashboard/state/client-state.js +++ b/src/observability/dashboard/state/client-state.js @@ -7,6 +7,10 @@ export function toClientState(state) { return { ...rest, evidenceCount: (evidence ?? []).length, + evidenceRecent: (evidence ?? []).slice(-30).reverse().map((entry) => ({ + ts: entry.ts, task_id: entry.task_id, kind: entry.kind, status: entry.status, evidence: entry.evidence, + })), + artifactIndex: (run.artifactIndex ?? []).slice(0, 80), timeline: (run.timeline ?? []).slice(0, 120), totals: run.totals ?? null, stageElapsed: run.stageElapsed ?? {}, diff --git a/src/observability/dashboard/state/index.js b/src/observability/dashboard/state/index.js index 424b716..82a2a4e 100644 --- a/src/observability/dashboard/state/index.js +++ b/src/observability/dashboard/state/index.js @@ -2,7 +2,7 @@ import { join } from 'node:path'; import { evaluateAlerts } from '../../alerts/engine.js'; import { sourceRoots } from './roots.js'; import { getAllRuns } from './runs.js'; -import { getAllApprovals, buildBlockedGates, summarizeApprovals, resolveApprovalAcrossRoots } from './approvals.js'; +import { getAllApprovals, buildBlockedGates, approvalRequestsFromBlockedGates, summarizeApprovals, resolveApprovalAcrossRoots } from './approvals.js'; import { buildActivityFeed } from './feed.js'; import { buildStageMatrix } from './stage-matrix.js'; import { buildAgentGroups, buildAgentWork } from './agent-work.js'; @@ -30,8 +30,9 @@ export async function buildFullState(projectRoot, options = {}) { const today = new Date().toISOString().slice(0, 10); const todayRuns = runs.filter((run) => run.manifest?.created_at?.startsWith(today)); - const approvals = summarizeApprovals(queueApprovals); const blockedGates = buildBlockedGates(runs); + const actionableGateApprovals = approvalRequestsFromBlockedGates(blockedGates, queueApprovals); + const approvals = summarizeApprovals([...queueApprovals, ...actionableGateApprovals]); const feed = buildActivityFeed(runs); const frameworks = buildFrameworks(runs); const stageMatrix = buildStageMatrix(runs); diff --git a/src/observability/dashboard/state/runs.js b/src/observability/dashboard/state/runs.js index 54c3edc..0779429 100644 --- a/src/observability/dashboard/state/runs.js +++ b/src/observability/dashboard/state/runs.js @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs'; -import { readFile, readdir } from 'node:fs/promises'; +import { readFile, readdir, stat } from 'node:fs/promises'; import { join } from 'node:path'; import { CANONICAL_SDLC_STAGES } from '../../../core/harness/stages.js'; import { deriveRunTimeline, deriveRunTotals, deriveStageElapsed } from '../../metrics/derive.js'; @@ -92,6 +92,35 @@ export async function enrichTasks(projectRoot, runId, tasks) { })); } +// Index the run's real deliverables so the UI can offer them for reading: +// artifacts/ (top level + per stage), specs/, plan.md, context.md. +async function indexArtifacts(runDir) { + const index = []; + const push = async (relPath, stage) => { + try { + const info = await stat(join(runDir, relPath)); + if (info.isFile()) index.push({ path: relPath, stage, size: info.size }); + } catch { /* missing — skip */ } + }; + for (const name of ['plan.md', 'context.md']) await push(name, 'run'); + const artifactsDir = join(runDir, 'artifacts'); + try { + for (const entry of await readdir(artifactsDir, { withFileTypes: true })) { + if (entry.isFile()) await push(join('artifacts', entry.name), 'run'); + if (entry.isDirectory() && entry.name === 'stages') { + for (const stage of await readdir(join(artifactsDir, 'stages'))) { + try { + for (const file of await readdir(join(artifactsDir, 'stages', stage))) { + await push(join('artifacts', 'stages', stage, file), stage); + } + } catch { /* not a dir */ } + } + } + } + } catch { /* no artifacts dir */ } + return index.sort((a, b) => a.stage.localeCompare(b.stage) || a.path.localeCompare(b.path)); +} + export async function getRunsForRoot(projectRoot) { const runsDir = join(projectRoot, '.rstack', 'runs'); if (!existsSync(runsDir)) return []; @@ -137,6 +166,7 @@ export async function getRunsForRoot(projectRoot) { events, evidence, approvals: Array.isArray(runApprovals) ? runApprovals : [], + artifactIndex: await indexArtifacts(runDir), activityTimeline: buildActivityTimeline(events), timeline: deriveRunTimeline(events, rawTasks), totals: deriveRunTotals(events), diff --git a/src/observability/dashboard/ui/client.js b/src/observability/dashboard/ui/client.js index 572e7a3..b0694ef 100644 --- a/src/observability/dashboard/ui/client.js +++ b/src/observability/dashboard/ui/client.js @@ -1195,6 +1195,12 @@ function openDrawer(runId) { '' + (totals.quality_avg !== null && totals.quality_avg !== undefined ? Math.round(totals.quality_avg * 100) + '%' : '-') + 'Quality' + '$' + Number(cost).toFixed(4) + 'Cost' + '' + + 'Deliverables' + (run.artifactIndex || []).length + ' artifacts' + + artifactListHtml(run) + + '' + + 'Evidence' + (run.evidenceCount || 0) + ' records' + + evidenceListHtml(run) + + '' + 'Task Timeline' + ganttHtml(run.timeline || []) + '' + @@ -1207,6 +1213,52 @@ function openDrawer(runId) { document.getElementById('drawer-panel').classList.add('open'); } +function artifactListHtml(run) { + var items = run.artifactIndex || []; + if (!items.length) return emptyHtml('No artifacts yet', 'Stage deliverables (requirements, architecture, QA reports…) appear here.'); + var byStage = {}; + items.forEach(function(item) { (byStage[item.stage] = byStage[item.stage] || []).push(item); }); + return Object.keys(byStage).sort().map(function(stage) { + return '' + esc(stage) + '' + + byStage[stage].map(function(item) { + var name = item.path.split('/').pop(); + return '' + + '' + esc(name) + '' + Math.ceil((item.size || 0) / 1024) + ' KB'; + }).join('') + ''; + }).join(''); +} + +function evidenceListHtml(run) { + var entries = run.evidenceRecent || []; + if (!entries.length) return emptyHtml('No evidence yet', 'Validation evidence records appear here.'); + return entries.map(function(entry) { + return '' + pill(entry.status === 'PASS' ? 'pass' : 'fail', entry.status) + + '' + esc(entry.task_id || '') + '' + + '' + esc(entry.kind || '') + '' + + '' + (entry.ts ? fmtTime(entry.ts) : '') + ''; + }).join(''); +} + +function viewArtifact(btn) { + var runId = btn.getAttribute('data-runid'); + var path = btn.getAttribute('data-path'); + fetch('/api/artifact?run=' + encodeURIComponent(runId) + '&path=' + encodeURIComponent(path)) + .then(function(response) { return response.json(); }) + .then(function(data) { + if (data.error) { showErr('artifact: ' + data.error); return; } + var body = document.getElementById('drawer-body'); + var back = document.createElement('button'); + back.className = 'tb-chip'; + back.textContent = '← Back to run'; + back.addEventListener('click', function() { openDrawer(runId); }); + body.innerHTML = + '' + esc(data.path) + '' + Math.ceil(data.size / 1024) + ' KB' + + '' + esc(data.content) + ''; + body.insertBefore(back, body.firstChild); + }) + .catch(function(err) { showErr('artifact: ' + err.message); }); +} + function closeDrawer() { document.getElementById('drawer-overlay').classList.remove('open'); document.getElementById('drawer-panel').classList.remove('open'); @@ -1221,11 +1273,23 @@ function rejectFromButton(btn) { } function resolveApproval(id, action) { + var resolvedBy = localStorage.getItem('rstack-approver-name') || ''; + if (!resolvedBy && typeof window.prompt === 'function') { + resolvedBy = window.prompt('Manager name for this approval decision') || ''; + if (resolvedBy) localStorage.setItem('rstack-approver-name', resolvedBy); + } fetch('/api/' + action, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id: id }) - }).then(function() { return fetchState(); }).catch(function(err) { showErr('approval: ' + err.message); }); + body: JSON.stringify({ id: id, resolvedBy: resolvedBy || 'dashboard' }) + }).then(function(response) { + if (!response.ok) { + return response.json().then(function(body) { + throw new Error(body.error || ('HTTP ' + response.status)); + }); + } + return fetchState(); + }).catch(function(err) { showErr('approval: ' + err.message); }); } function fetchState() { diff --git a/src/observability/dashboard/ui/styles.js b/src/observability/dashboard/ui/styles.js index fe752f6..0bb3583 100644 --- a/src/observability/dashboard/ui/styles.js +++ b/src/observability/dashboard/ui/styles.js @@ -1100,4 +1100,21 @@ tr.clickable:hover td { background: #f8fbff; } @media (max-width: 1100px) { .studio-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } } @media (max-width: 700px) { .studio-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .studio-hud { display: none; } } + +/* ── Artifact & evidence browser (run drawer) ─────────────────────────────── */ +.artifact-group { margin-bottom: 10px; } +.artifact-stage { font-size: 10px; color: var(--faint); letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 4px; } +.artifact-link { + display: flex; justify-content: space-between; align-items: center; gap: 10px; + width: 100%; text-align: left; font: inherit; font-size: 12px; + padding: 7px 10px; margin-bottom: 4px; cursor: pointer; + background: var(--soft); border: 1px solid var(--line); border-radius: 6px; color: var(--text); +} +.artifact-link:hover { border-color: var(--line-strong); background: #fff; } +.artifact-content { + margin: 0; font-family: 'JetBrains Mono', monospace; font-size: 11px; line-height: 1.55; + white-space: pre-wrap; word-break: break-word; max-height: 60vh; overflow: auto; +} +.evidence-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--line); font-size: 12px; } +.evidence-row:last-child { border-bottom: none; } `; diff --git a/tests/dashboard-business-hub.test.js b/tests/dashboard-business-hub.test.js index d151c10..8f821d1 100644 --- a/tests/dashboard-business-hub.test.js +++ b/tests/dashboard-business-hub.test.js @@ -4,14 +4,14 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { buildFullState } from '../src/observability/dashboard/state/index.js'; +import { buildFullState, resolveDashboardApproval } from '../src/observability/dashboard/state/index.js'; import { dashboardHtml } from '../src/observability/dashboard/ui.js'; async function writeJson(filePath, value) { await writeFile(filePath, JSON.stringify(value, null, 2)); } -test('Business Hub keeps historical blocked gates out of actionable pending approvals', async () => { +test('Business Hub turns blocked gates into actionable pending approvals', async () => { const projectRoot = mkdtempSync(join(tmpdir(), 'rstack-business-state-')); try { const runId = '2026-05-31T10-00-00-demo'; @@ -78,9 +78,10 @@ test('Business Hub keeps historical blocked gates out of actionable pending appr const state = await buildFullState(projectRoot, { includeRegistry: false }); - assert.deepEqual(state.pendingApprovals.map((a) => a.id), ['queue-1']); - assert.equal(state.approvalStats.pending, 1); - assert.equal(state.approvalStats.total, 2); + assert.ok(state.pendingApprovals.some((a) => a.id === 'queue-1')); + assert.ok(state.pendingApprovals.some((a) => a.artifact === 'deploy-approval.md')); + assert.equal(state.approvalStats.pending, 2); + assert.equal(state.approvalStats.total, 3); assert.ok( state.feed.some((event) => event.type === 'approval_gate_blocked'), 'blocked gate history should remain visible in the live feed', @@ -94,6 +95,46 @@ test('Business Hub keeps historical blocked gates out of actionable pending appr } }); +test('Business Hub approval resolution writes the run-level approval artifact', async () => { + const projectRoot = mkdtempSync(join(tmpdir(), 'rstack-business-approve-')); + try { + const runId = '2026-05-31T11-00-00-demo'; + const runDir = join(projectRoot, '.rstack', 'runs', runId); + await mkdir(runDir, { recursive: true }); + + await writeJson(join(runDir, 'manifest.json'), { + run_id: runId, + goal: 'Approval source of truth', + created_at: '2026-05-31T11:00:00.000Z', + framework: 'pi', + }); + await writeJson(join(runDir, 'tasks.json'), { tasks: [] }); + await writeFile(join(runDir, 'events.jsonl'), JSON.stringify({ + ts: '2026-05-31T11:01:00.000Z', + type: 'approval_gate_blocked', + task_id: '004-implementation', + missing: ['architecture.md'], + }) + '\n'); + + const state = await buildFullState(projectRoot, { includeRegistry: false }); + const approval = state.pendingApprovals.find((item) => item.artifact === 'architecture.md'); + assert.ok(approval, 'blocked gate becomes a pending dashboard approval'); + + const ok = await resolveDashboardApproval(projectRoot, approval.id, 'approved', 'Manager Maya', { includeRegistry: false }); + assert.equal(ok, true); + + const runApprovals = JSON.parse(await readFile(join(runDir, 'approvals.json'), 'utf8')); + assert.equal(runApprovals.at(-1).artifact, 'architecture.md'); + assert.equal(runApprovals.at(-1).status, 'APPROVED'); + assert.equal(runApprovals.at(-1).approver, 'Manager Maya'); + + const after = await buildFullState(projectRoot, { includeRegistry: false }); + assert.ok(!after.pendingApprovals.some((item) => item.id === approval.id), 'resolved approval leaves the pending queue'); + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + } +}); + test('Business Hub client does not overwrite a live WebSocket label after HTTP state loads', () => { const html = dashboardHtml(3008); diff --git a/tests/people-layer-approvals.test.js b/tests/people-layer-approvals.test.js index bfd96d6..c1ef069 100644 --- a/tests/people-layer-approvals.test.js +++ b/tests/people-layer-approvals.test.js @@ -77,6 +77,7 @@ test('people layer + policy enforcement E2E', async (t) => { // on the first task — the gate must block. mkdirSync(join(projectRoot, '.rstack'), { recursive: true }); writeFileSync(join(projectRoot, '.rstack', 'policy.json'), JSON.stringify({ + managers: ['Lead Lena'], required_approvals: { '001-product-clarification': ['release-readiness.json'] }, })); @@ -88,6 +89,11 @@ test('people layer + policy enforcement E2E', async (t) => { .split('\n').filter(Boolean).map((line) => JSON.parse(line)); assert.ok(events.some((event) => event.type === 'approval_gate_blocked'), 'blocked event recorded'); + await assert.rejects( + () => mockPi.tools.sdlc_approve.execute('7a', { run_id: runId, artifact: 'release-readiness.json', status: 'APPROVED', approver: 'Intern Ivy' }), + /not allowed by manager policy/, + ); + // Approve per policy → gate opens, task starts with real agent attribution. await mockPi.tools.sdlc_approve.execute('7', { run_id: runId, artifact: 'release-readiness.json', status: 'APPROVED', approver: 'Lead Lena' }); const approvals = JSON.parse(readFileSync(join(runDir, 'approvals.json'), 'utf8'));
' + esc(data.content) + '