diff --git a/README.md b/README.md index 49e2296..5ccb7a7 100644 --- a/README.md +++ b/README.md @@ -9,21 +9,22 @@ [![Install in VS Code](https://img.shields.io/badge/Install_in_VS_Code-0098FF?logo=visualstudiocode)](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522dotdog%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522-y%2522%252C%2522dotdog%2540latest%2522%252C%2522serve%2522%255D%257D) [![Install in Cursor](https://img.shields.io/badge/Install_in_Cursor-1a1a1a?logo=cursor)](https://cursor.com/install-mcp?name=dotdog&config=%7B%22command%22%3A%22npx%20-y%20dotdog%40latest%20serve%22%7D) -> **Spec-driven design for agent-ready projects.** Turn plans or existing repos into structured `.dog` specs, validate them, compile them into `.dag` graphs, and let agents query real project structure instead of guessing. +> **Spec-driven design for agent-ready projects and workspaces.** Turn plans, repos, or multi-repo products into structured `.dog` specs, compile `.dag` graphs, and let agents query real project structure instead of guessing. ## What dotdog does -dotdog is a spec-driven design toolchain. +dotdog is a spec-driven design toolchain for single repos, monorepos, and polyrepo workspaces. -It supports two starting points: +It supports three starting points: 1. **Empty project** — create a spec foundation before implementation. -2. **Existing project** — map the current repo into a structured spec workspace. +2. **Existing project** — map the current repo into structured graph facts. +3. **Workspace product** — describe a product made from N repositories with `.doghouse/workspace.json`. The core flow: ```text -plan -> spec workspace -> validation -> implementation graph -> agent execution +plan -> spec workspace -> validation -> repo/workspace graph -> agent execution ``` `.dog` files are the human-readable source specs. `.dag` files are compiled implementation graphs for agents and tools. @@ -41,10 +42,20 @@ Requires Node.js >= 20 or Bun >= 1.3. ## Quick Start ```bash -dotdog init my-project # create a spec workspace -dotdog validate # check spec completeness -dotdog compile # build the .dag implementation graph -dotdog serve # expose the graph to MCP-compatible agents +dotdog init my-project # create a spec workspace +dotdog validate # check spec completeness +dotdog compile # build the .dag implementation graph +dotdog serve # expose the graph to MCP-compatible agents +``` + +For an existing product or organization workspace: + +```bash +dotdog workspace init --id example-workspace +dotdog workspace add ../example-service --alias example-service --role api +dotdog workspace add ../example-interface --alias example-interface --role web +dotdog workspace validate +dotdog workspace graph --json ``` ## What init creates @@ -92,6 +103,40 @@ dotdog validate dotdog compile ``` +## Workspaces for N repos + +A Dotdog workspace is a product boundary. It can contain one repo, a monorepo, or N separate repositories. + +Workspace metadata lives in: + +```text +.doghouse/workspace.json +``` + +Example: + +```json +{ + "version": 1, + "workspace": { "id": "example-workspace", "name": "example-workspace" }, + "repos": [ + { "alias": "example-service", "role": "api", "path": "../example-service" }, + { "alias": "example-interface", "role": "web", "path": "../example-interface" } + ], + "groups": [], + "edges": [] +} +``` + +The workspace graph emits repo-qualified facts so humans and agents can distinguish where a fact came from: + +```text +example-service:src/routes/core-flow.ts +example-interface:src/features/core-flow/index.ts +``` + +No manifest is required for single-repo projects; Dotdog treats the current repo as a one-repo workspace by default. + ## Repo mapping direction dotdog should not only scaffold empty projects. It should also map existing repos. @@ -121,7 +166,13 @@ See [Spec-Driven Repo Mapping](docs/spec-driven-repo-mapping.md) for the formal | `dotdog tokens [dir]` | Count tokens in `.dog` files and compare to compiled `.dag` savings. | | `dotdog index [dir]` | Build search index for semantic queries across compiled specs. | | `dotdog search ` | Semantic search across compiled specs using the search index. | -| `dotdog serve [dir]` | Start MCP server over stdio. AI agents query specs without hallucination. | +| `dotdog serve [dir]` | Start MCP server over stdio. AI agents query specs and workspace metadata without hallucination. | +| `dotdog workspace init --id ` | Create `.doghouse/workspace.json` for a repo or product workspace. | +| `dotdog workspace add ` | Add a repository to the workspace manifest with `--alias` and `--role`. | +| `dotdog workspace list` | List workspace repos and groups. Use `--json` for structured output. | +| `dotdog workspace validate` | Validate workspace manifest aliases, paths, groups, and edges. | +| `dotdog workspace graph` | Emit deterministic workspace graph JSON. | +| `dotdog map [dir]` | Inspect an existing repo and generate graph-ready `.dog` facts plus `repo.dag`. | | `dotdog simulate ` | Walk through a scenario. Reads SPEC.dog scenarios, checks pre/postconditions. | | `dotdog predictions [dir]` | List all predictions with status (pending, correct, wrong, partial). | | `dotdog resolve ` | Mark a prediction as correct, wrong, or partial with evidence. | @@ -139,7 +190,7 @@ Planned: | Command | Description | |---------|-------------| | `dotdog init --map` | Create a spec workspace and seed it from the current repo. | -| `dotdog map [dir]` | Inspect an existing repo and generate graph-ready `.dog` facts. | +| Cross-repo trace/search | Infer relationships across workspace repos beyond explicit manifest edges. | ## File Formats @@ -174,11 +225,11 @@ JSON graph compiled from `.dog` files. Nodes, edges, properties, and states in a Example graph facts: ```text -CheckoutPage renders CartSummary -CheckoutPage calls POST /api/checkout -POST /api/checkout writes orders -POST /api/checkout depends_on STRIPE_SECRET_KEY -orders implemented_by prisma/schema.prisma +CoreFlowPage renders StatusPanel +CoreFlowPage calls POST /api/core-flow +POST /api/core-flow writes records +POST /api/core-flow depends_on SERVICE_TOKEN +records implemented_by prisma/schema.prisma ``` ## MCP Server : AI Agent Integration @@ -193,11 +244,12 @@ orders implemented_by prisma/schema.prisma | `schema` | Property definitions only : zero prose, agent-optimized | | `summary` | Node count, edge count, file count, compile time | | `listProjects` | Array of project names | +| `workspace.list` | Structured workspace metadata with repos, groups, and `trustedAsInstruction: false` | Agent workflow: ```text -listProjects -> getEntity -> traverse graph +workspace.list -> listProjects -> getEntity -> traverse graph ``` ## Dogfood diff --git a/docs/blog/ai-coding-agents-need-structured-specs.md b/docs/blog/ai-coding-agents-need-structured-specs.md index ad4dd92..626eedb 100644 --- a/docs/blog/ai-coding-agents-need-structured-specs.md +++ b/docs/blog/ai-coding-agents-need-structured-specs.md @@ -25,7 +25,7 @@ Here's a real-world product spec: ``` Users can sign up with email and password. Each user belongs to one -organization. Organizations have billing plans... +organization. Organizations have access rules... ``` An AI agent reads this and builds a user system. But it fills in blanks with plausible-sounding hallucinations: diff --git a/docs/blog/dotdog-cursor.md b/docs/blog/dotdog-cursor.md index 1b945a4..cf7718a 100644 --- a/docs/blog/dotdog-cursor.md +++ b/docs/blog/dotdog-cursor.md @@ -49,7 +49,7 @@ $ dotdog compile Then ask Cursor: -You: Build a checkout flow for the Order entity +You: Build a core-flow flow for the Order entity Cursor (agent mode): → getEntity("Order") @@ -57,7 +57,7 @@ Cursor (agent mode): → getEntity("Payment") → getEntity("Cart") -Generates complete checkout component with: +Generates complete core-flow component with: - Order status flow (pending → confirmed → shipped) - Payment integration with exact field names - Cart-to-Order conversion diff --git a/docs/blog/index.md b/docs/blog/index.md index a4820ae..3649889 100644 --- a/docs/blog/index.md +++ b/docs/blog/index.md @@ -12,6 +12,7 @@ Notes on agent-first documentation, structured specs, MCP, ARD, and the agent in ## Posts +- [Observed Workspace Graphs](observed-workspace-graphs) — June 24, 2026 - [Google's Agentic Resource Discovery Is the Missing Layer of the Agent Internet](google-ard-dotdog) — June 18, 2026 - [Karpathy's LLM OS Needs a Brain. Here's What That Looks Like.](karpathy-wiki-brain) — June 18, 2026 - [Agent-First Documentation — The New Standard](agent-first-documentation) — June 17, 2026 diff --git a/docs/blog/karpathy-wiki-brain.md b/docs/blog/karpathy-wiki-brain.md index de0d1e4..43062e0 100644 --- a/docs/blog/karpathy-wiki-brain.md +++ b/docs/blog/karpathy-wiki-brain.md @@ -54,7 +54,7 @@ Entity: Payment → Webhook: triggers Entity: Customer - Properties: email (string), payment_methods (array), billing_address (object) + Properties: email (string), display_name (string), preferences (object) States: active → suspended → deleted Relationships: → Payment: initiates diff --git a/docs/blog/live-contracts.md b/docs/blog/live-contracts.md index 0420b79..172dd12 100644 --- a/docs/blog/live-contracts.md +++ b/docs/blog/live-contracts.md @@ -22,7 +22,7 @@ That's it. One command. dotdog scans your `.dog` files for `type: endpoint` enti ✓ memory-api ✓ user-api ⚠ search-api: https://backup.search.sh (backup, primary failed) - ✗ billing-api: https://api.billing.sh — missing fields: invoice_count + ✗ status-api: https://api.example.test — missing fields: status_code 4 endpoints: 2 passed, 1 degraded, 1 failed ``` diff --git a/docs/blog/observed-workspace-graphs.md b/docs/blog/observed-workspace-graphs.md new file mode 100644 index 0000000..965e74f --- /dev/null +++ b/docs/blog/observed-workspace-graphs.md @@ -0,0 +1,47 @@ +--- +layout: default +title: "Observed Workspace Graphs" +description: "dotdog observe, ask, and drift turn a software workspace into deterministic graph facts." +--- + +← [Blog](index) + +# Observed Workspace Graphs + +Most software tools treat a repository as a pile of files. Humans and agents then rediscover the same facts over and over: which files exist, which routes are present, which specs mention a behavior, which tests cover a surface, and which generated artifacts are stale. + +Dotdog's observed workspace graph is a deterministic snapshot of that reality. + +```bash +dotdog observe +dotdog ask "which files define routes?" +dotdog drift +``` + +`dotdog observe` walks the current repo or workspace, maps implementation surfaces, and writes machine-readable artifacts under `.doghouse/`: + +```text +.doghouse/observed.json +.doghouse/facts.jsonl +.doghouse/workspace.dag +``` + +The important unit is a cited graph fact. A fact has a subject, predicate, object, source, confidence, and optional repo/file location. That makes it small enough for tools to query and concrete enough for humans to inspect. + +```json +{ + "subject": "src/routes/status.ts", + "predicate": "is", + "object": "api_route", + "source": "code", + "confidence": "compiled" +} +``` + +`dotdog ask` is intentionally deterministic. It does not need a model to answer basic workspace questions. It searches observed facts first and returns matching source paths. + +`dotdog drift` checks whether observed facts are missing, stale, or pointing at files that no longer exist. It is a local safety check before heavier automation, CI gates, or agent workflows. + +This gives Dotdog a simple public contract: map the workspace, write cited facts, query the facts, and report drift. + +The goal is not to replace source code or specs. The goal is to stop every person and every agent from starting at zero. diff --git a/docs/index.md b/docs/index.md index f897869..e0adc35 100644 --- a/docs/index.md +++ b/docs/index.md @@ -61,6 +61,20 @@ You write specs before code. Five minutes to set up. Zero configuration. | Compile | `dotdog compile` | Build a positional DAG graph — 94% smaller, optimized for LLM context | | Expose | `dotdog serve` | Start an MCP server. AI agents query via six structured tools | +## Observed workspace graph + +Dotdog can also observe an existing repo or multi-repo workspace and write deterministic graph artifacts: + +```bash +dotdog observe +dotdog ask "which files define routes?" +dotdog drift +``` + +`observe` writes `.doghouse/observed.json`, `.doghouse/facts.jsonl`, and `.doghouse/workspace.dag`. `ask` queries those facts without an LLM dependency. `drift` reports stale or missing observed references. + +Read more: [Observed Workspace Graphs](blog/observed-workspace-graphs). + ## For AI agents Six MCP tools for structured queries — no scanning, no guessing: diff --git a/docs/spec-driven-repo-mapping.md b/docs/spec-driven-repo-mapping.md index e6a1bb7..94b3d22 100644 --- a/docs/spec-driven-repo-mapping.md +++ b/docs/spec-driven-repo-mapping.md @@ -127,41 +127,41 @@ Agents should be able to ask: ## Example Map Output ```dog -### Entity: CheckoutPage +### Entity: CoreFlowPage -Frontend checkout screen. +Frontend core-flow screen. ```yaml -entity: CheckoutPage +entity: CoreFlowPage type: component -file: src/app/checkout/page.tsx +file: src/app/core-flow/page.tsx renders: - - CartSummary + - StatusPanel calls: - - POST /api/checkout + - POST /api/core-flow depends_on: - - NEXT_PUBLIC_STRIPE_KEY + - NEXT_PUBLIC_SERVICE_URL implements: - - Checkout Flow + - CoreFlow Flow ``` -### Entity: Checkout API +### Entity: CoreFlow API -Backend endpoint that creates checkout sessions. +Backend endpoint that creates core-flow sessions. ```yaml -entity: Checkout API +entity: CoreFlow API type: api_route -route: POST /api/checkout -file: src/app/api/checkout/route.ts +route: POST /api/core-flow +file: src/app/api/core-flow/route.ts calls: - - Stripe Checkout Session + - CoreFlow Service writes: - - orders + - records depends_on: - - STRIPE_SECRET_KEY + - SERVICE_TOKEN implements: - - Checkout Flow + - CoreFlow Flow ``` ``` diff --git a/docs/v0.9-plan.md b/docs/v0.9-plan.md index 5aea4cc..c862f6b 100644 --- a/docs/v0.9-plan.md +++ b/docs/v0.9-plan.md @@ -202,7 +202,7 @@ $ dotdog doctor Environment ✓ git 2.48.1 - ✓ gh 2.65.0 (authenticated as logohere) + ✓ gh 2.65.0 (authenticated) ✓ node 26.3.0 ✓ dotdog 0.8.4 (latest) diff --git a/package.json b/package.json index 172ce7c..6a06131 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "packages/*" ], "scripts": { - "build": "bun run --filter '*' build", + "build": "bun run --cwd packages/dotdog build", "lint": "bun run --filter '*' lint", "test": "bun test", "pack:check": "node scripts/check-pack.mjs", diff --git a/packages/dotdog/README.md b/packages/dotdog/README.md index 7fd3e74..76f56fe 100644 --- a/packages/dotdog/README.md +++ b/packages/dotdog/README.md @@ -5,7 +5,7 @@ [![License: MIT](https://img.shields.io/npm/l/dotdog)](https://github.com/specdog/dotdog/blob/main/LICENSE) [![CI](https://github.com/specdog/dotdog/actions/workflows/test.yml/badge.svg)](https://github.com/specdog/dotdog/actions) -> **Feed the dog. Ship with specs.** Write .dog specs. Dog checks them. AI agents fetch them. +> **Feed the dog. Ship with specs.** Write `.dog` specs, map repos into `.dag` graphs, and expose single-repo or N-repo workspaces to agents. ## Install @@ -18,9 +18,19 @@ Requires Node.js >= 20. ## Quick Start ```bash -dotdog init my-project # scaffold a spec genome -dotdog validate # score completeness (0-100%) -dotdog analyze # deep analysis : gaps, suggestions, entity audit +dotdog init my-project # scaffold a spec workspace +dotdog validate # score completeness (0-100%) +dotdog analyze # deep analysis: gaps, suggestions, entity audit +``` + +For an existing multi-repo product: + +```bash +dotdog workspace init --id example-workspace +dotdog workspace add ../example-service --alias example-service --role api +dotdog workspace add ../example-interface --alias example-interface --role web +dotdog workspace validate +dotdog workspace graph --json ``` ## Commands @@ -32,18 +42,24 @@ dotdog analyze # deep analysis : gaps, suggestions, entity audit | `dotdog parse ` | Parse a `.dog` file into sections. | | `dotdog compile [dir]` | Compile `.dog` files into a `.dag` graph (JSON). | | `dotdog visualize [dir]` | Output Mermaid graph from `.dag`. `--save` writes `.md` for GitHub rendering. | -| `dotdog serve [dir]` | Start MCP server over stdio. AI agents query specs without hallucination. | +| `dotdog serve [dir]` | Start MCP server over stdio. AI agents query specs and workspace metadata without hallucination. | +| `dotdog workspace init --id ` | Create `.doghouse/workspace.json` for one repo or a product workspace. | +| `dotdog workspace add ` | Add a repository to a workspace with `--alias` and `--role`. | +| `dotdog workspace list` | List workspace repos and groups. | +| `dotdog workspace validate` | Validate workspace aliases, paths, groups, and edges. | +| `dotdog workspace graph` | Emit deterministic workspace graph JSON. | +| `dotdog map [dir]` | Inspect an existing repo and generate graph-ready `.dog` facts plus `repo.dag`. | | `dotdog staleness [dir]` | Detect drift between spec and reality. Compares plan.dog tasks against code. | | `dotdog generate [dir]` | Generate missing spec files from SPEC.dog (data-model, COPY, INDEX). | | `dotdog simulate ` | Run a simulation scenario. Reads SPEC.dog scenarios, checks pre/postconditions. | -| `dotdog init ` | Scaffold a new spec genome project with templates. | +| `dotdog init ` | Scaffold a new spec workspace project with templates. | | `dotdog list` | List all projects and their `.dog` file counts. | ## File Formats -### `.dog` : Human-Written Spec Genome +### `.dog` : Human-Written Source Spec -Markdown prose + YAML structured blocks. Free and open source. Define entities, relationships, events, predictions, and copy in a single format that both humans and parsers understand. +Markdown prose + YAML structured blocks. Free and open source. Define entities, relationships, events, predictions, implementation facts, and copy in a single format that both humans and parsers understand. ```markdown ### Entity: User @@ -71,7 +87,7 @@ JSON graph compiled from `.dog` files. Nodes, edges, properties, and states in a ## MCP Server : AI Agent Integration -`dotdog serve` exposes specs to any MCP-compatible AI agent over stdio. Six tools: +`dotdog serve` exposes specs and workspace metadata to any MCP-compatible AI agent over stdio. | Tool | Description | |------|-------------| @@ -81,8 +97,9 @@ JSON graph compiled from `.dog` files. Nodes, edges, properties, and states in a | `schema` | Property definitions only : zero prose, agent-optimized | | `summary` | Node count, edge count, file count, compile time | | `listProjects` | Array of project names | +| `workspace.list` | Structured workspace metadata with repos, groups, and `trustedAsInstruction: false` | -Agent workflow: `listProjects` → `getEntity` → `traverse` graph. +Agent workflow: `workspace.list` → `listProjects` → `getEntity` → `traverse` graph. ## Dogfood @@ -117,7 +134,7 @@ cp -r extensions/vscode ~/.vscode/extensions/dotdog ## Spec-Driven Development -dotdog is built for SDD. Write your spec first. Validate it. Compile it. Let AI agents query it. The spec is the source of truth. +dotdog is built for SDD. Write your spec first, or map an existing repo/workspace. Validate it. Compile it. Let AI agents query it. The spec and graph are the source of truth. ``` spec → validate → compile → serve → AI agent queries diff --git a/packages/dotdog/__tests__/commands.test.ts b/packages/dotdog/__tests__/commands.test.ts index ef9ed52..6d605c1 100644 --- a/packages/dotdog/__tests__/commands.test.ts +++ b/packages/dotdog/__tests__/commands.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from 'bun:test'; import { $, which } from 'bun'; -import { mkdtempSync, rmSync, existsSync } from 'fs'; +import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { setupTempProject } from './helpers'; @@ -82,7 +82,7 @@ describe('untested commands', () => { const dir = mkdtempSync(join(tmpdir(), 'dotdog-test-analyze-')); try { setupTempProject(dir, 'testproj'); - const result = await $`cd ${dir} && ${BUN} ${ROOT}/packages/dotdog/src/cli.ts analyze`.nothrow(); + const result = await $`cd ${dir} && ${BUN} ${ROOT}/packages/dotdog/src/cli.ts analyze`.quiet().nothrow(); expect(result.text()).toContain('complete'); } finally { rmSync(dir, { recursive: true, force: true }); @@ -117,7 +117,7 @@ describe('untested commands', () => { setupTempProject(dir, 'testproj'); await $`cd ${dir} && ${BUN} ${ROOT}/packages/dotdog/src/cli.ts compile`.quiet(); // Simulate should not crash — returns partial success if no scenario steps - const result = await $`cd ${dir} && ${BUN} ${ROOT}/packages/dotdog/src/cli.ts simulate compile`.nothrow(); + const result = await $`cd ${dir} && ${BUN} ${ROOT}/packages/dotdog/src/cli.ts simulate compile`.quiet().nothrow(); expect([0, 1]).toContain(result.exitCode); } finally { rmSync(dir, { recursive: true, force: true }); @@ -132,6 +132,56 @@ describe('untested commands', () => { expect(out).toContain('repo.dag'); expect(existsSync(join(dir, '.doghouse', 'generated', 'repo-map.dog'))).toBe(true); expect(existsSync(join(dir, '.doghouse', 'generated', 'repo.dag'))).toBe(true); + const factsFile = join(dir, '.doghouse', 'generated', 'facts.jsonl'); + expect(existsSync(factsFile)).toBe(true); + const facts = readFileSync(factsFile, 'utf-8').trim().split('\n').map((line) => JSON.parse(line)); + expect(facts.some((fact) => fact.subject === 'repository' && fact.predicate === 'is')).toBe(true); + expect(facts.every((fact) => fact.repo === 'repo-world-test')).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test('observe writes observed workspace artifacts', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dotdog-test-observe-')); + try { + setupTempProject(dir, 'testproj'); + const out = await $`cd ${dir} && ${BUN} ${ROOT}/packages/dotdog/src/cli.ts observe`.text(); + expect(out).toContain('Observed workspace'); + const observedFile = join(dir, '.doghouse', 'observed.json'); + const factsFile = join(dir, '.doghouse', 'facts.jsonl'); + const workspaceDagFile = join(dir, '.doghouse', 'workspace.dag'); + expect(existsSync(observedFile)).toBe(true); + expect(existsSync(factsFile)).toBe(true); + expect(existsSync(workspaceDagFile)).toBe(true); + const observed = JSON.parse(readFileSync(observedFile, 'utf-8')); + expect(observed.repos.length).toBe(1); + expect(observed.factCount).toBeGreaterThan(0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test('ask queries observed facts', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dotdog-test-ask-')); + try { + setupTempProject(dir, 'testproj'); + await $`cd ${dir} && ${BUN} ${ROOT}/packages/dotdog/src/cli.ts observe`.quiet(); + const out = await $`cd ${dir} && ${BUN} ${ROOT}/packages/dotdog/src/cli.ts ask repository`.text(); + expect(out).toContain('Question: repository'); + expect(out).toContain('repository is repo'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test('drift reports clean observed facts', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dotdog-test-drift-')); + try { + setupTempProject(dir, 'testproj'); + await $`cd ${dir} && ${BUN} ${ROOT}/packages/dotdog/src/cli.ts observe`.quiet(); + const out = await $`cd ${dir} && ${BUN} ${ROOT}/packages/dotdog/src/cli.ts drift`.text(); + expect(out).toContain('No drift found.'); } finally { rmSync(dir, { recursive: true, force: true }); } diff --git a/packages/dotdog/__tests__/regression.test.ts b/packages/dotdog/__tests__/regression.test.ts index 77074eb..75547d7 100644 --- a/packages/dotdog/__tests__/regression.test.ts +++ b/packages/dotdog/__tests__/regression.test.ts @@ -72,7 +72,7 @@ describe('regression', () => { const dir = mkdtempSync(join(tmpdir(), 'dotdog-test-layers-')); try { mkdirSync(join(dir, '.doghouse', 'semantic'), { recursive: true }); - writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'example-web-app', version: '1.0.0' }, null, 2)); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'example-interface-app', version: '1.0.0' }, null, 2)); writeFileSync(join(dir, 'railway.json'), JSON.stringify({ startCommand: 'npm start' }, null, 2)); writeFileSync(join(dir, '.doghouse', 'semantic', 'deployment.dog'), [ '## Deployment', diff --git a/packages/dotdog/__tests__/workspace.test.ts b/packages/dotdog/__tests__/workspace.test.ts index cd95b78..3f2b18a 100644 --- a/packages/dotdog/__tests__/workspace.test.ts +++ b/packages/dotdog/__tests__/workspace.test.ts @@ -11,22 +11,22 @@ function fixtureWorkspace() { const root = mkdtempSync(path.join(tmpdir(), 'dotdog-workspace-')); const doghouse = path.join(root, '.doghouse'); const repos = path.join(root, 'repos'); - mkdirSync(path.join(repos, 'example-web'), { recursive: true }); - mkdirSync(path.join(repos, 'example-api'), { recursive: true }); + mkdirSync(path.join(repos, 'example-interface'), { recursive: true }); + mkdirSync(path.join(repos, 'example-service'), { recursive: true }); mkdirSync(path.join(repos, 'example-worker'), { recursive: true }); mkdirSync(doghouse, { recursive: true }); writeFileSync(path.join(doghouse, 'workspace.json'), JSON.stringify({ version: 1, - workspace: { id: 'example-product', name: 'example-product' }, + workspace: { id: 'example-workspace', name: 'example-workspace' }, repos: [ - { alias: 'example-web', role: 'web', path: '../repos/example-web' }, - { alias: 'example-api', role: 'api', path: '../repos/example-api' }, + { alias: 'example-interface', role: 'web', path: '../repos/example-interface' }, + { alias: 'example-service', role: 'api', path: '../repos/example-service' }, { alias: 'example-worker', role: 'worker', path: '../repos/example-worker' }, ], - groups: [{ name: 'checkout', repos: ['example-web', 'example-api', 'example-worker'] }], + groups: [{ name: 'core-flow', repos: ['example-interface', 'example-service', 'example-worker'] }], edges: [ - { from: 'example-web', to: 'example-api', type: 'http' }, - { from: 'example-api', to: 'example-worker', type: 'event' }, + { from: 'example-interface', to: 'example-service', type: 'http' }, + { from: 'example-service', to: 'example-worker', type: 'event' }, ], }, null, 2)); return root; @@ -36,12 +36,12 @@ describe('workspace bridge', () => { test('validates duplicate aliases and unknown edges', () => { const result = validateWorkspaceConfig({ version: 1, - workspace: { id: 'example-product' }, + workspace: { id: 'example-workspace' }, repos: [ - { alias: 'example-api', path: '.' }, - { alias: 'example-api', path: '.' }, + { alias: 'example-service', path: '.' }, + { alias: 'example-service', path: '.' }, ], - edges: [{ from: 'example-api', to: 'example-worker', type: 'http' }], + edges: [{ from: 'example-service', to: 'example-worker', type: 'http' }], }); expect(result.valid).toBe(false); expect(result.errors.map((error) => error.code)).toContain('duplicate_repo_alias'); @@ -52,10 +52,10 @@ describe('workspace bridge', () => { const root = fixtureWorkspace(); const context = resolveWorkspace(root); expect(context.mode).toBe('workspace'); - expect(context.repos.map((repo) => repo.alias)).toEqual(['example-web', 'example-api', 'example-worker']); + expect(context.repos.map((repo) => repo.alias)).toEqual(['example-interface', 'example-service', 'example-worker']); const graph = buildWorkspaceGraph(context); - expect(graph.workspace).toBe('example-product'); - expect(graph.nodes.map((node) => node.id)).toContain('repo:example-api'); + expect(graph.workspace).toBe('example-workspace'); + expect(graph.nodes.map((node) => node.id)).toContain('repo:example-service'); expect(graph.edges.map((edge) => edge.type)).toContain('http'); }); diff --git a/packages/dotdog/kits/ecommerce/SPEC.dog b/packages/dotdog/kits/ecommerce/SPEC.dog index 0d0e70b..2758e33 100644 --- a/packages/dotdog/kits/ecommerce/SPEC.dog +++ b/packages/dotdog/kits/ecommerce/SPEC.dog @@ -2,7 +2,7 @@ ## Product -An online store. Customers browse products, add to cart, checkout, and track orders. +An online store. Customers browse products, add to cart, core flow, and track records. ## What the User Sees @@ -19,10 +19,10 @@ An online store. Customers browse products, add to cart, checkout, and track ord | [Add to Cart] | +------------------------------------------+ -### Screen: Checkout +### Screen: Core Flow +------------------------------------------+ -| Checkout | +| Core Flow | |------------------------------------------| | Shipping: Alex Smith | | 123 Main St, NY 10001 | diff --git a/packages/dotdog/kits/saas/data-model.dog b/packages/dotdog/kits/saas/data-model.dog index bb66a62..8b12a4b 100644 --- a/packages/dotdog/kits/saas/data-model.dog +++ b/packages/dotdog/kits/saas/data-model.dog @@ -71,7 +71,7 @@ properties: price_cents: type: number required: true - billing_cycle: + plan_cycle: type: enum required: true values: [monthly, annual] diff --git a/packages/dotdog/package.json b/packages/dotdog/package.json index 0a96dd4..38ba8d7 100644 --- a/packages/dotdog/package.json +++ b/packages/dotdog/package.json @@ -48,6 +48,7 @@ "url": "https://github.com/specdog/dotdog/issues" }, "scripts": { - "lint": "bun run --check src/" + "lint": "bun run --check src/", + "build": "bun build src/cli.ts --outdir dist --target node" } } diff --git a/packages/dotdog/src/cli.ts b/packages/dotdog/src/cli.ts index c890010..31f017a 100644 --- a/packages/dotdog/src/cli.ts +++ b/packages/dotdog/src/cli.ts @@ -1799,6 +1799,272 @@ workspaceCmd console.log(JSON.stringify(buildWorkspaceGraph(context), null, 2)); }); + +type ObservedRepoResult = { + alias: string; + role?: string; + path: string; + scanned: number; + facts: number; + edges: number; + observedFacts: number; + artifacts: { + repoMap: string; + repoDag: string; + facts: string; + }; +}; + +function readJsonl(file: string): any[] { + if (!existsSync(file)) return []; + return readFileSync(file, 'utf-8') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => JSON.parse(line)); +} + +function writeJsonl(file: string, rows: any[]): void { + writeFileSync(file, rows.map((row) => JSON.stringify(row)).join('\n') + (rows.length ? '\n' : '')); +} + +function normalizedRelative(from: string, to: string): string { + return relative(from, to).replace(/\\/g, '/') || '.'; +} + + +function factText(fact: any): string { + return [fact.subject, fact.predicate, fact.object, fact.file || '', fact.repo || ''].join(' ').toLowerCase(); +} + +function scoreFact(fact: any, terms: string[]): number { + const subject = String(fact.subject || '').toLowerCase(); + const predicate = String(fact.predicate || '').toLowerCase(); + const object = String(fact.object || '').toLowerCase(); + const file = String(fact.file || '').toLowerCase(); + const text = factText(fact); + let score = 0; + for (const term of terms) { + if (subject === term || object === term || file === term) score += 12; + else if (subject.includes(term) || object.includes(term)) score += 6; + if (predicate.includes(term)) score += 4; + if (file.includes(term)) score += 5; + if (text.includes(term)) score += 1; + } + return score; +} + +function queryTerms(query: string): string[] { + return query + .toLowerCase() + .replace(/[^a-z0-9._:/-]+/g, ' ') + .split(/\s+/) + .map((term) => term.trim()) + .filter((term) => term.length > 2); +} + +program + .command('ask ') + .description('Ask a deterministic question over observed graph facts') + .option('--facts ', 'path to facts.jsonl', '.doghouse/facts.jsonl') + .option('-l, --limit ', 'max results', '10') + .option('--json', 'print JSON') + .action((question, opts) => { + try { + const factsFile = resolve(opts.facts); + if (!existsSync(factsFile)) { + console.error(chalk.red(`No observed facts found: ${opts.facts}. Run dotdog observe first.`)); + process.exitCode = 1; + return; + } + const facts = readJsonl(factsFile); + const terms = queryTerms(question); + const matches = facts + .map((fact) => ({ fact, score: scoreFact(fact, terms) })) + .filter((result) => result.score > 0) + .sort((a, b) => b.score - a.score || String(a.fact.id).localeCompare(String(b.fact.id))) + .slice(0, Number(opts.limit || 10)); + + if (opts.json) { + console.log(JSON.stringify({ question, matches }, null, 2)); + return; + } + + console.log(`Question: ${question}`); + if (!matches.length) { + console.log('No matching facts found.'); + return; + } + console.log(`Matches: ${matches.length}`); + for (const [index, result] of matches.entries()) { + const fact = result.fact; + console.log(`${index + 1}. ${fact.subject} ${fact.predicate} ${fact.object}`); + if (fact.file) console.log(` source: ${fact.repo ? `${fact.repo}:` : ''}${fact.file}`); + } + } catch (error) { + console.error(chalk.red(String(error instanceof Error ? error.message : error))); + process.exitCode = 1; + } + }); + + +type DriftIssue = { + severity: 'HIGH' | 'MEDIUM' | 'LOW'; + code: string; + message: string; + file?: string; +}; + +function driftIssueRank(issue: DriftIssue): string { + const rank = issue.severity === 'HIGH' ? '0' : issue.severity === 'MEDIUM' ? '1' : '2'; + return `${rank}:${issue.code}:${issue.file || ''}:${issue.message}`; +} + +program + .command('drift') + .description('Detect stale or inconsistent observed graph artifacts') + .option('--facts ', 'path to facts.jsonl', '.doghouse/facts.jsonl') + .option('--json', 'print JSON') + .action((opts) => { + try { + const context = resolveWorkspace(process.cwd(), { requireManifest: false }); + const factsFile = resolve(opts.facts); + const issues: DriftIssue[] = []; + + if (!existsSync(factsFile)) { + issues.push({ severity: 'HIGH', code: 'missing_facts', message: `Observed facts not found: ${opts.facts}` }); + } else { + const factsMtime = statSync(factsFile).mtimeMs; + const facts = readJsonl(factsFile); + const seenFiles = new Set(); + + for (const fact of facts) { + if (!fact.file || seenFiles.has(`${fact.repo || ''}:${fact.file}`)) continue; + seenFiles.add(`${fact.repo || ''}:${fact.file}`); + const repo = fact.repo ? context.registry.get(String(fact.repo)) : null; + const baseDir = repo?.cwd || context.workspaceRoot; + const sourceFile = resolve(baseDir, String(fact.file)); + + if (!existsSync(sourceFile)) { + issues.push({ severity: 'HIGH', code: 'missing_fact_file', message: `Fact references missing file: ${fact.file}`, file: fact.file }); + continue; + } + + if (statSync(sourceFile).mtimeMs > factsMtime) { + issues.push({ severity: 'LOW', code: 'stale_facts', message: `Source file is newer than observed facts: ${fact.file}`, file: fact.file }); + } + } + } + + issues.sort((a, b) => driftIssueRank(a).localeCompare(driftIssueRank(b))); + + if (opts.json) { + console.log(JSON.stringify({ ok: issues.length === 0, issues }, null, 2)); + return; + } + + if (!issues.length) { + console.log('No drift found.'); + return; + } + + for (const issue of issues) { + console.log(`${issue.severity} ${issue.code}: ${issue.message}`); + } + } catch (error) { + console.error(chalk.red(String(error instanceof Error ? error.message : error))); + process.exitCode = 1; + } + }); + +program + .command('observe') + .description('Observe the current repo or workspace and write queryable graph artifacts') + .option('--json', 'print JSON') + .option('--repo ', 'observe one workspace repo') + .option('--group ', 'observe one workspace group') + .action((opts) => { + try { + if (opts.repo && opts.group) { + console.error(chalk.red('Use either --repo or --group, not both.')); + process.exitCode = 1; + return; + } + + const context = resolveWorkspace(process.cwd(), { requireManifest: false }); + const selectedRepos = opts.repo + ? [context.registry.require(opts.repo)] + : opts.group + ? context.registry.group(opts.group) + : context.registry.list(); + const repos = [...selectedRepos].sort((a, b) => a.alias.localeCompare(b.alias)); + const doghouseDir = join(context.workspaceRoot, '.doghouse'); + const observedDir = join(doghouseDir, 'observed'); + mkdirSync(observedDir, { recursive: true }); + + const workspaceGraph = buildWorkspaceGraph(context); + const repoResults: ObservedRepoResult[] = []; + const allFacts: any[] = []; + + for (const repo of repos) { + const repoOutDir = join(observedDir, repo.alias); + const result = writeRepoMap(repo.cwd, repo.alias, repoOutDir); + const repoFacts = readJsonl(result.factsFile); + allFacts.push(...repoFacts); + repoResults.push({ + alias: repo.alias, + role: repo.role, + path: normalizedRelative(context.workspaceRoot, repo.cwd), + scanned: result.scanned, + facts: result.facts, + edges: result.edges, + observedFacts: result.observedFacts, + artifacts: { + repoMap: normalizedRelative(context.workspaceRoot, result.file), + repoDag: normalizedRelative(context.workspaceRoot, result.dagFile), + facts: normalizedRelative(context.workspaceRoot, result.factsFile), + }, + }); + } + + allFacts.sort((a, b) => String(a.id).localeCompare(String(b.id))); + const factsFile = join(doghouseDir, 'facts.jsonl'); + const workspaceDagFile = join(doghouseDir, 'workspace.dag'); + const observedFile = join(doghouseDir, 'observed.json'); + const observed = { + version: 1, + workspace: context.config.workspace, + mode: context.mode, + repos: repoResults, + factCount: allFacts.length, + artifacts: { + facts: normalizedRelative(context.workspaceRoot, factsFile), + workspaceDag: normalizedRelative(context.workspaceRoot, workspaceDagFile), + }, + }; + + writeJsonl(factsFile, allFacts); + writeFileSync(workspaceDagFile, `${JSON.stringify(workspaceGraph, null, 2)}\n`); + writeFileSync(observedFile, `${JSON.stringify(observed, null, 2)}\n`); + + if (opts.json) { + console.log(JSON.stringify(observed, null, 2)); + return; + } + + console.log(`Observed workspace: ${context.config.workspace.id}`); + console.log(`Repos: ${repoResults.length}`); + console.log(`Facts written: ${allFacts.length}`); + console.log('Artifacts:'); + console.log(` ${normalizedRelative(context.workspaceRoot, observedFile)}`); + console.log(` ${normalizedRelative(context.workspaceRoot, workspaceDagFile)}`); + console.log(` ${normalizedRelative(context.workspaceRoot, factsFile)}`); + } catch (error) { + console.error(chalk.red(String(error instanceof Error ? error.message : error))); + process.exitCode = 1; + } + }); + program .command('map [dir]') .description('Map a repository into a machine-readable repo.dag world model') diff --git a/packages/dotdog/src/map/repoMapper.ts b/packages/dotdog/src/map/repoMapper.ts index aaccd6d..03b8580 100644 --- a/packages/dotdog/src/map/repoMapper.ts +++ b/packages/dotdog/src/map/repoMapper.ts @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs'; import { join } from 'path'; import { renderRepositoryDag } from '../dag/repoWorld'; +import { stableId } from '../dag/schema'; import { isIgnoredRepoPath } from '../workspace/paths'; export type RepoMapFact = { @@ -17,6 +18,18 @@ export type RepoMapEdge = { cardinality?: string; }; +export type GraphFact = { + id: string; + subject: string; + predicate: string; + object: string; + repo?: string; + file?: string; + line?: number; + confidence: 'explicit' | 'compiled' | 'inferred'; + source: 'spec' | 'code' | 'manifest' | 'package'; +}; + export type RepoMap = { facts: RepoMapFact[]; edges: RepoMapEdge[]; @@ -26,8 +39,10 @@ export type RepoMap = { export type RepoMapWriteResult = { file: string; dagFile: string; + factsFile: string; facts: number; edges: number; + observedFacts: number; scanned: number; }; @@ -198,6 +213,48 @@ function dogString(value: string): string { return String(value).replace(/`/g, "'").trim(); } +function graphFactSource(fact: RepoMapFact): GraphFact['source'] { + if (fact.type === 'package') return 'package'; + if (fact.type === 'manifest' || fact.type === 'lockfile' || fact.type === 'deployment_config') return 'manifest'; + return 'code'; +} + +export function toGraphFacts(map: RepoMap, repo?: string): GraphFact[] { + const facts: GraphFact[] = []; + + for (const fact of map.facts) { + const file = fact.properties?.path; + facts.push({ + id: stableId('fact', repo || 'repo', fact.name, 'is', fact.type), + subject: fact.name, + predicate: 'is', + object: fact.type, + ...(repo ? { repo } : {}), + ...(file ? { file } : {}), + confidence: 'compiled', + source: graphFactSource(fact), + }); + } + + for (const edge of map.edges) { + facts.push({ + id: stableId('fact', repo || 'repo', edge.source, edge.verb, edge.target), + subject: edge.source, + predicate: edge.verb, + object: edge.target, + ...(repo ? { repo } : {}), + confidence: 'compiled', + source: 'code', + }); + } + + return facts.sort((a, b) => a.id.localeCompare(b.id)); +} + +export function renderGraphFactsJsonl(facts: GraphFact[]): string { + return facts.map((fact) => JSON.stringify(fact)).join('\n') + (facts.length ? '\n' : ''); +} + export function renderRepoMapDog(project: string, root: string, map: RepoMap): string { const lines: string[] = []; lines.push('# Repo Map'); @@ -250,15 +307,20 @@ export function writeRepoMap(targetDir: string, projectName: string, specDir: st const outFile = join(specDir, 'repo-map.dog'); const dagFile = join(specDir, 'repo.dag'); + const factsFile = join(specDir, 'facts.jsonl'); + const observedFacts = toGraphFacts(map, projectName); writeFileSync(outFile, renderRepoMapDog(projectName, targetDir, map)); writeFileSync(dagFile, renderRepositoryDag(projectName, targetDir, map)); + writeFileSync(factsFile, renderGraphFactsJsonl(observedFacts)); return { file: outFile, dagFile, + factsFile, facts: map.facts.length, edges: map.edges.length, + observedFacts: observedFacts.length, scanned: map.files.length, }; } diff --git a/packages/dotdog/src/workspace/privacy.ts b/packages/dotdog/src/workspace/privacy.ts index b98120c..7417ecb 100644 --- a/packages/dotdog/src/workspace/privacy.ts +++ b/packages/dotdog/src/workspace/privacy.ts @@ -1,13 +1,13 @@ const ALLOWED_PUBLIC_EXAMPLES = new Set([ 'example-org', - 'example-product', - 'example-api', - 'example-web', + 'example-workspace', + 'example-service', + 'example-interface', 'example-mobile', 'example-worker', 'example-ops', - 'checkout', - 'billing', + 'core-flow', + 'access-control', 'catalog', 'customer-portal', 'admin-dashboard',