dotdog 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/README.md +52 -106
  2. package/dist/cli.js +244 -73
  3. package/package.json +13 -17
  4. package/CHANGELOG.md +0 -35
  5. package/LICENSE +0 -21
package/README.md CHANGED
@@ -1,130 +1,76 @@
1
- # dotdog
1
+ # spec-platform
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/dotdog)](https://www.npmjs.com/package/dotdog)
4
- [![npm downloads](https://img.shields.io/npm/dm/dotdog)](https://www.npmjs.com/package/dotdog)
5
- [![License: MIT](https://img.shields.io/npm/l/dotdog)](https://github.com/specdog/dotdog/blob/main/LICENSE)
6
- [![CI](https://github.com/specdog/dotdog/actions/workflows/test.yml/badge.svg)](https://github.com/specdog/dotdog/actions)
3
+ Monorepo for the Spec Platform — a knowledge graph system where specs ARE the database and LLMs ARE the query engine.
7
4
 
8
- > **Feed the dog. Ship with specs.** Write .dog specs. Dog checks them. AI agents fetch them.
5
+ ## The Flywheel
9
6
 
10
- ## Install
11
-
12
- ```bash
13
- npm install -g dotdog
14
- ```
15
-
16
- Requires Node.js >= 20.
17
-
18
- ## Quick Start
19
-
20
- ```bash
21
- dotdog init my-project # scaffold a spec genome
22
- dotdog validate # score completeness (0-100%)
23
- dotdog analyze # deep analysis : gaps, suggestions, entity audit
24
7
  ```
25
-
26
- ## Commands
27
-
28
- | Command | Description |
29
- |---------|-------------|
30
- | `dotdog validate [dir]` | Score spec completeness. Checks file existence, entity descriptions, section counts. |
31
- | `dotdog analyze [dir]` | Deep analysis. Detects domain, stack, gaps with severity, entity quality audit. |
32
- | `dotdog parse <file>` | Parse a `.dog` file into sections. |
33
- | `dotdog compile [dir]` | Compile `.dog` files into a `.dag` graph (JSON). |
34
- | `dotdog visualize [dir]` | Output Mermaid graph from `.dag`. `--save` writes `.md` for GitHub rendering. |
35
- | `dotdog serve [dir]` | Start MCP server over stdio. AI agents query specs without hallucination. |
36
- | `dotdog staleness [dir]` | Detect drift between spec and reality. Compares plan.dog tasks against code. |
37
- | `dotdog generate [dir]` | Generate missing spec files from SPEC.dog (data-model, COPY, INDEX). |
38
- | `dotdog simulate <scenario>` | Run a simulation scenario. Reads SPEC.dog scenarios, checks pre/postconditions. |
39
- | `dotdog init <project>` | Scaffold a new spec genome project with templates. |
40
- | `dotdog list` | List all projects and their `.dog` file counts. |
41
-
42
- ## File Formats
43
-
44
- ### `.dog` : Human-Written Spec Genome
45
-
46
- 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.
47
-
48
- ```markdown
49
- ### Entity: User
50
-
51
- A person who uses the app.
52
-
53
- ` ``yaml
54
- entity: User
55
- type: entity
56
- properties:
57
- id:
58
- type: string
59
- required: true
60
- email:
61
- type: string
62
- required: true
63
- states: [active, suspended]
64
- lifecycle: active → suspended
65
- ` ``
8
+ spec → validate → app → data → better spec → better app → ...
66
9
  ```
67
10
 
68
- ### `.dag` : Machine-Compiled Graph
69
-
70
- JSON graph compiled from `.dog` files. Nodes, edges, properties, and states in a deterministic structure. 85% token savings vs raw `.dog` files for AI agents.
71
-
72
- ## MCP Server : AI Agent Integration
73
-
74
- `dotdog serve` exposes specs to any MCP-compatible AI agent over stdio. Six tools:
11
+ The spec describes the platform. The platform validates the spec. The validation report improves the spec. Each cycle adds granularity.
75
12
 
76
- | Tool | Description |
77
- |------|-------------|
78
- | `getEntity` | Exact entity with properties, states, lifecycle, and connected edges |
79
- | `traverse` | BFS subgraph from any starting node to any depth |
80
- | `search` | Find entities by name or type |
81
- | `schema` | Property definitions only : zero prose, agent-optimized |
82
- | `summary` | Node count, edge count, file count, compile time |
83
- | `listProjects` | Array of project names |
84
-
85
- Agent workflow: `listProjects` → `getEntity` → `traverse` graph.
86
-
87
- ## Dogfood
88
-
89
- dotdog validates its own specs. Every PR:
13
+ ## Structure
90
14
 
91
15
  ```
92
- dotdog validate → find gaps → fix spec → PR → merge → tag → CI publish
16
+ spec-platform/
17
+ ├── packages/
18
+ │ ├── spec-engine/ # Core types and ontology (shared by everything)
19
+ │ ├── spec-mcp/ # MCP Server — AI agents query specs via stdio
20
+ │ └── spec-cli/ # CLI — spec validate, init, simulate, list
21
+ ├── projects/ # Spec genomes (dogfooding)
22
+ │ └── spec-platform/
23
+ │ └── specs/
24
+ │ ├── SPEC.dog # Product spec — screens, flows, stories
25
+ │ ├── constitution.dog # Immutable rules
26
+ │ └── data-model.dog # Graph ontology — nodes, edges, tasks, predictions, vectors
27
+ ├── templates/ # Spec genome templates for new projects
28
+ └── package.json # Bun workspace root
93
29
  ```
94
30
 
95
- Eat your own dogfood. The tool is the project.
31
+ ## Quick Start
96
32
 
97
- ## VS Code Extension
33
+ ```bash
34
+ bun install
35
+ cd projects/spec-platform/specs
98
36
 
99
- Syntax highlighting for `.dog` files. Install:
37
+ # Validate our own spec (dogfood)
38
+ bun ../../../packages/spec-cli/src/index.ts validate ../..
100
39
 
101
- ```bash
102
- cp -r extensions/vscode ~/.vscode/extensions/dotdog
40
+ # List projects
41
+ bun ../../../packages/spec-cli/src/index.ts list
103
42
  ```
104
43
 
105
- ## Format Specifications
44
+ ## $0 Stack
106
45
 
107
- - [`.dog` format spec](spec/format-spec.dog) : language definition, EBNF grammar, validation rules
108
- - [`.dag` format spec](spec/format-spec-dag.dog) : graph definition, MCP API, token efficiency
46
+ | Component | Technology | Cost |
47
+ |-----------|-----------|------|
48
+ | Runtime | Bun | $0 |
49
+ | Database | bun:sqlite (embedded) | $0 |
50
+ | Types | TypeScript (strict) | $0 |
51
+ | CLI | Commander.js + chalk | $0 |
52
+ | MCP Server | @modelcontextprotocol/sdk (stdio) | $0 |
53
+ | Embeddings | all-MiniLM-L6-v2 (local) | $0 |
54
+ | Hosting | None needed (local-first) | $0 |
109
55
 
110
- ## Links
56
+ ## The Spec Graph
111
57
 
112
- - **GitHub:** [specdog/dotdog](https://github.com/specdog/dotdog)
113
- - **npm:** [dotdog](https://www.npmjs.com/package/dotdog)
114
- - **Docs:** [GitHub Pages](https://specdog.github.io/dotdog)
115
- - **llms.txt:** [llms.txt](llms.txt) : structured for AI agent discovery
116
- - **AGENTS.md:** [AGENTS.md](AGENTS.md) : instructions for AI coding agents
58
+ The spec is not a document. It's a knowledge graph.
117
59
 
118
- ## Spec-Driven Development
60
+ - **Nodes**: entities, tasks, predictions, screens, constraints, user stories
61
+ - **Edges**: contains, depends_on, implements, references, calls, precedes
62
+ - **Vectors**: every section embedded for semantic search, contradiction detection, staleness checks
63
+ - **Predictions**: forecasts with triggers, timeframes, confidence, and actual outcome tracking
119
64
 
120
- 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.
65
+ LLMs traverse the graph at query time. They don't read prose and guess they get exact typed values.
121
66
 
122
- ```
123
- spec → validate → compile → serve → AI agent queries
124
- ```
125
-
126
- No more specs that rot in a wiki. No more agents guessing from prose. One source. Zero ambiguity.
67
+ ## Score
127
68
 
128
- ## License
69
+ ```
70
+ spec validate → 43% complete
129
71
 
130
- MIT
72
+ ✓ SPEC.dog
73
+ ✓ constitution.dog
74
+ ✓ data-model.dog
75
+ ⚠ COPY.dog, DESIGN-SYSTEM.dog, plan.dog, INDEX.dog
76
+ ```
package/dist/cli.js CHANGED
@@ -2537,7 +2537,6 @@ var source_default = chalk;
2537
2537
  import { existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, mkdirSync, writeFileSync } from "fs";
2538
2538
  import { join as join2, resolve as resolve2 } from "path";
2539
2539
  import { homedir as homedir2 } from "os";
2540
- import { createHash } from "crypto";
2541
2540
 
2542
2541
  // src/parser.ts
2543
2542
  function parse(source) {
@@ -2553,7 +2552,14 @@ function parseToJSON(source) {
2553
2552
  function parseSections(lines) {
2554
2553
  const sections = [];
2555
2554
  let i = 0;
2556
- const rootBlocks = parseBlocks(lines, 0, lines.length);
2555
+ let firstHeading = lines.length;
2556
+ for (let j = 0;j < lines.length; j++) {
2557
+ if (/^##\s/.test(lines[j])) {
2558
+ firstHeading = j;
2559
+ break;
2560
+ }
2561
+ }
2562
+ const rootBlocks = parseBlocks(lines, 0, firstHeading);
2557
2563
  if (rootBlocks.length > 0) {
2558
2564
  sections.push({
2559
2565
  kind: "section",
@@ -2574,7 +2580,7 @@ function parseSections(lines) {
2574
2580
  const sectionStart = i;
2575
2581
  i++;
2576
2582
  const end = findSectionEnd(lines, i, level);
2577
- const blocks = parseBlocks(lines, i, end);
2583
+ const blocks = parseBlocks(lines, sectionStart, end);
2578
2584
  sections.push({
2579
2585
  kind: "section",
2580
2586
  level,
@@ -2593,9 +2599,7 @@ function parseSections(lines) {
2593
2599
  function findSectionEnd(lines, start, currentLevel) {
2594
2600
  for (let i = start;i < lines.length; i++) {
2595
2601
  const line = lines[i];
2596
- if (/^##\s/.test(line))
2597
- return i;
2598
- if (currentLevel === 2 && /^###\s/.test(line))
2602
+ if (/^##\s/.test(line) || /^###\s/.test(line))
2599
2603
  return i;
2600
2604
  }
2601
2605
  return lines.length;
@@ -2632,6 +2636,15 @@ function parseBlocks(lines, start, end) {
2632
2636
  continue;
2633
2637
  }
2634
2638
  }
2639
+ const predMatch = line.match(/^###\s+Prediction:\s*(.+)/);
2640
+ if (predMatch) {
2641
+ const result = parseStructuredBlock(lines, i, end, "prediction", predMatch[1]);
2642
+ if (result) {
2643
+ blocks.push(result.node);
2644
+ i = result.nextLine;
2645
+ continue;
2646
+ }
2647
+ }
2635
2648
  if (/^\|.+\|/.test(line) && i + 1 < end && /^\|[-| ]+\|/.test(lines[i + 1])) {
2636
2649
  const table = parseTable(lines, i, end);
2637
2650
  if (table) {
@@ -2711,6 +2724,12 @@ function parseStructuredBlock(lines, start, end, kind, headerRest) {
2711
2724
  nextLine: i
2712
2725
  };
2713
2726
  }
2727
+ if (kind === "prediction") {
2728
+ return {
2729
+ node: buildPredictionNode(headerRest, description, yaml, start, i),
2730
+ nextLine: i
2731
+ };
2732
+ }
2714
2733
  return null;
2715
2734
  }
2716
2735
  function buildEntityNode(name, description, yaml, lineStart, lineEnd) {
@@ -2731,7 +2750,11 @@ function buildEntityNode(name, description, yaml, lineStart, lineEnd) {
2731
2750
  }
2732
2751
  const states = Array.isArray(yaml.states) ? yaml.states : [];
2733
2752
  const lifecycleStr = yaml.lifecycle || "";
2734
- const lifecycle = lifecycleStr ? lifecycleStr.split(/\s*→\s*/) : [];
2753
+ const lifecycleParts = lifecycleStr ? lifecycleStr.split(/\s*→\s*/).map((s) => s.trim()) : [];
2754
+ const lifecycle = [];
2755
+ for (let si = 0;si < lifecycleParts.length - 1; si++) {
2756
+ lifecycle.push(`${lifecycleParts[si]} → ${lifecycleParts[si + 1]}`);
2757
+ }
2735
2758
  return {
2736
2759
  kind: "entity",
2737
2760
  name,
@@ -2754,6 +2777,7 @@ function buildRelationshipNode(headerRest, description, yaml, lineStart, lineEnd
2754
2777
  source,
2755
2778
  target,
2756
2779
  verb: yaml.verb || "connects",
2780
+ description: description || yaml.description || "",
2757
2781
  cardinality: yaml.cardinality || "N:M",
2758
2782
  required: yaml.required === true,
2759
2783
  cascade: yaml.cascade || "none",
@@ -2778,6 +2802,22 @@ function buildEventNode(name, description, yaml, lineStart, lineEnd) {
2778
2802
  lineEnd
2779
2803
  };
2780
2804
  }
2805
+ function buildPredictionNode(name, description, yaml, lineStart, lineEnd) {
2806
+ return {
2807
+ kind: "prediction",
2808
+ statement: name,
2809
+ description,
2810
+ trigger: yaml.trigger || "",
2811
+ timeframe: yaml.timeframe || "",
2812
+ confidence: typeof yaml.confidence === "number" ? yaml.confidence : 0,
2813
+ measurement: yaml.measurement || "",
2814
+ preconditions: Array.isArray(yaml.preconditions) ? yaml.preconditions : [],
2815
+ postconditions: Array.isArray(yaml.postconditions) ? yaml.postconditions : [],
2816
+ yaml,
2817
+ lineStart: lineStart + 1,
2818
+ lineEnd
2819
+ };
2820
+ }
2781
2821
  function parseTable(lines, start, end) {
2782
2822
  const headerLine = lines[start];
2783
2823
  const headers = headerLine.split("|").map((h) => h.trim()).filter(Boolean);
@@ -2850,15 +2890,25 @@ function parseSimpleYAML(lines) {
2850
2890
  } else if (value.startsWith("[") && value.endsWith("]")) {
2851
2891
  currentObj[nestedKey] = value.slice(1, -1).split(",").map((s) => s.trim());
2852
2892
  } else {
2853
- const inlineObj = parseInlineObject(value);
2854
- if (inlineObj) {
2855
- currentObj[nestedKey] = inlineObj;
2856
- } else {
2857
- currentObj[nestedKey] = value;
2858
- }
2893
+ currentObj[nestedKey] = value;
2859
2894
  }
2860
2895
  continue;
2861
2896
  }
2897
+ if (nestedMatch && !inNested) {
2898
+ const key = nestedMatch[1];
2899
+ const value = (nestedMatch[2] || "").trim();
2900
+ if (value === "true")
2901
+ result[key] = true;
2902
+ else if (value === "false")
2903
+ result[key] = false;
2904
+ else if (/^-?\d+(\.\d+)?$/.test(value))
2905
+ result[key] = parseFloat(value);
2906
+ else if (value.startsWith("[") && value.endsWith("]"))
2907
+ result[key] = value.slice(1, -1).split(",").map((s) => s.trim());
2908
+ else
2909
+ result[key] = value;
2910
+ continue;
2911
+ }
2862
2912
  const deepMatch = line.match(/^\s{4}(\w[\w_]*):\s*(.+)?$/);
2863
2913
  if (deepMatch && inNested && nestedKey && typeof currentObj[nestedKey] === "object") {
2864
2914
  const deepNested = currentObj[nestedKey];
@@ -2906,13 +2956,27 @@ function resolvePath(p) {
2906
2956
  const resolved = p.startsWith("/") ? p : join(process.cwd(), p);
2907
2957
  if (!p.startsWith("/") && !p.startsWith("~")) {
2908
2958
  const rel = resolve(process.cwd(), p);
2909
- if (!rel.startsWith(process.cwd() + "/") && rel !== process.cwd()) {
2959
+ const cwd = process.cwd();
2960
+ const isDescendant = rel.startsWith(cwd + "/");
2961
+ const isSelf = rel === cwd;
2962
+ const isAncestor = cwd.startsWith(rel + "/");
2963
+ if (!isDescendant && !isSelf && !isAncestor) {
2910
2964
  throw new Error(`Path traversal blocked: ${p}`);
2911
2965
  }
2912
2966
  return rel;
2913
2967
  }
2914
2968
  return resolved;
2915
2969
  }
2970
+ var N = (dag) => dag.n || dag.nodes || [];
2971
+ var E = (dag) => dag.e || dag.edges || [];
2972
+ var P = (dag) => dag.p || dag.project || "";
2973
+ var ni = (n) => n.i || n.id || "";
2974
+ var nt = (n) => n.t || n.type || "";
2975
+ var np = (n) => n.p || n.properties || {};
2976
+ var ns = (n) => n.s || n.states || [];
2977
+ var nl = (n) => n.l || n.lifecycle || [];
2978
+ var es = (e) => e.s || e.source || "";
2979
+ var et = (e) => e.t || e.target || "";
2916
2980
  function serve(dir = ".") {
2917
2981
  const root = resolvePath(dir);
2918
2982
  const dagCache = new Map;
@@ -2954,7 +3018,7 @@ function serve(dir = ".") {
2954
3018
  { name: "traverse", description: "Traverse graph: from node, follow edges, return subgraph", inputSchema: { type: "object", properties: { project: { type: "string" }, from: { type: "string" }, depth: { type: "number", default: 1 } }, required: ["from"] } },
2955
3019
  { name: "search", description: "Find entities by name or type", inputSchema: { type: "object", properties: { project: { type: "string" }, q: { type: "string" }, type: { type: "string" } }, required: ["q"] } },
2956
3020
  { name: "listProjects", description: "List all projects", inputSchema: { type: "object", properties: {} } },
2957
- { name: "summary", description: "Get project summary: node count, edge count, completeness", inputSchema: { type: "object", properties: { project: { type: "string" } } } },
3021
+ { name: "summary", description: "Get project summary: node count, edge count, token savings", inputSchema: { type: "object", properties: { project: { type: "string" } } } },
2958
3022
  { name: "schema", description: "Get full property schema for an entity", inputSchema: { type: "object", properties: { project: { type: "string" }, entity: { type: "string" } }, required: ["entity"] } }
2959
3023
  ]
2960
3024
  }
@@ -2970,16 +3034,16 @@ function serve(dir = ".") {
2970
3034
  const dag = dagCache.get(args.project || [...dagCache.keys()][0] || "");
2971
3035
  if (!dag)
2972
3036
  return { jsonrpc: "2.0", id, error: { code: 404, message: "Project not found" } };
2973
- return { jsonrpc: "2.0", id, result: { content: [{ type: "text", text: JSON.stringify(dag.nodes || []) }] } };
3037
+ return { jsonrpc: "2.0", id, result: { content: [{ type: "text", text: JSON.stringify(N(dag)) }] } };
2974
3038
  }
2975
3039
  if (name === "getEntity") {
2976
3040
  const dag = dagCache.get(args.project || [...dagCache.keys()][0] || "");
2977
3041
  if (!dag)
2978
3042
  return { jsonrpc: "2.0", id, error: { code: 404, message: "Project not found" } };
2979
- const node = (dag.nodes || []).find((n) => n.id.toLowerCase() === (args.name || "").toLowerCase());
3043
+ const node = N(dag).find((n) => ni(n).toLowerCase() === (args.name || "").toLowerCase());
2980
3044
  if (!node)
2981
3045
  return { jsonrpc: "2.0", id, result: { content: [{ type: "text", text: "{}" }] } };
2982
- const edges = (dag.edges || []).filter((e) => e.source.toLowerCase() === node.id.toLowerCase() || e.target.toLowerCase() === node.id.toLowerCase());
3046
+ const edges = E(dag).filter((e) => es(e).toLowerCase() === ni(node).toLowerCase() || et(e).toLowerCase() === ni(node).toLowerCase());
2983
3047
  return { jsonrpc: "2.0", id, result: { content: [{ type: "text", text: JSON.stringify({ ...node, edges }) }] } };
2984
3048
  }
2985
3049
  if (name === "traverse") {
@@ -2987,22 +3051,27 @@ function serve(dir = ".") {
2987
3051
  if (!dag)
2988
3052
  return { jsonrpc: "2.0", id, error: { code: 404, message: "Project not found" } };
2989
3053
  const depth = Math.min(Math.max(1, args.depth || 1), 20);
2990
- const visited = new Set;
3054
+ const visitedNodes = new Set;
3055
+ const visitedEdges = new Set;
2991
3056
  const subgraph = { nodes: [], edges: [] };
2992
3057
  const queue = [{ id: args.from, depth: 0 }];
2993
3058
  while (queue.length > 0) {
2994
3059
  const curr = queue.shift();
2995
- if (visited.has(curr.id) || curr.depth > depth)
3060
+ if (visitedNodes.has(curr.id) || curr.depth > depth)
2996
3061
  continue;
2997
- visited.add(curr.id);
2998
- const node = (dag.nodes || []).find((n) => n.id.toLowerCase() === curr.id.toLowerCase());
3062
+ visitedNodes.add(curr.id);
3063
+ const node = N(dag).find((n) => ni(n).toLowerCase() === curr.id.toLowerCase());
2999
3064
  if (node)
3000
3065
  subgraph.nodes.push(node);
3001
- const edges = (dag.edges || []).filter((e) => e.source.toLowerCase() === curr.id.toLowerCase() || e.target.toLowerCase() === curr.id.toLowerCase());
3066
+ const edges = E(dag).filter((e) => es(e).toLowerCase() === curr.id.toLowerCase() || et(e).toLowerCase() === curr.id.toLowerCase());
3002
3067
  for (const e of edges) {
3003
- subgraph.edges.push(e);
3004
- const next = e.source.toLowerCase() === curr.id.toLowerCase() ? e.target : e.source;
3005
- if (!visited.has(next))
3068
+ const edgeKey = `${es(e)}→${et(e)}`;
3069
+ if (!visitedEdges.has(edgeKey)) {
3070
+ visitedEdges.add(edgeKey);
3071
+ subgraph.edges.push(e);
3072
+ }
3073
+ const next = es(e).toLowerCase() === curr.id.toLowerCase() ? et(e) : es(e);
3074
+ if (!visitedNodes.has(next))
3006
3075
  queue.push({ id: next, depth: curr.depth + 1 });
3007
3076
  }
3008
3077
  }
@@ -3014,19 +3083,21 @@ function serve(dir = ".") {
3014
3083
  return { jsonrpc: "2.0", id, error: { code: 404, message: "Project not found" } };
3015
3084
  const q = (args.q || "").toLowerCase();
3016
3085
  const type = (args.type || "").toLowerCase();
3017
- const results = (dag.nodes || []).filter((n) => n.id.toLowerCase().includes(q) && (!type || (n.type || "").toLowerCase().includes(type)));
3086
+ const results = N(dag).filter((n) => ni(n).toLowerCase().includes(q) && (!type || nt(n).toLowerCase().includes(type)));
3018
3087
  return { jsonrpc: "2.0", id, result: { content: [{ type: "text", text: JSON.stringify(results) }] } };
3019
3088
  }
3020
3089
  if (name === "summary") {
3021
3090
  const dag = dagCache.get(args.project || [...dagCache.keys()][0] || "");
3022
3091
  if (!dag)
3023
3092
  return { jsonrpc: "2.0", id, error: { code: 404, message: "Project not found" } };
3093
+ const tk = dag.tk || dag.tokens || {};
3024
3094
  const s = {
3025
- project: dag.project,
3026
- nodes: (dag.nodes || []).length,
3027
- edges: (dag.edges || []).length,
3028
- files: dag.files || dag.count || 0,
3029
- compiled: dag.compiled_at
3095
+ project: P(dag),
3096
+ nodes: N(dag).length,
3097
+ edges: E(dag).length,
3098
+ version: dag.v || dag.version || "",
3099
+ savings: tk.sv || tk.savings_pct || 0,
3100
+ method: tk.m || tk.method || ""
3030
3101
  };
3031
3102
  return { jsonrpc: "2.0", id, result: { content: [{ type: "text", text: JSON.stringify(s) }] } };
3032
3103
  }
@@ -3034,14 +3105,14 @@ function serve(dir = ".") {
3034
3105
  const dag = dagCache.get(args.project || [...dagCache.keys()][0] || "");
3035
3106
  if (!dag)
3036
3107
  return { jsonrpc: "2.0", id, error: { code: 404, message: "Project not found" } };
3037
- const node = (dag.nodes || []).find((n) => n.id.toLowerCase() === (args.entity || "").toLowerCase());
3108
+ const node = N(dag).find((n) => ni(n).toLowerCase() === (args.entity || "").toLowerCase());
3038
3109
  if (!node)
3039
3110
  return { jsonrpc: "2.0", id, error: { code: 404, message: "Entity not found" } };
3040
3111
  return { jsonrpc: "2.0", id, result: { content: [{ type: "text", text: JSON.stringify({
3041
- entity: node.id,
3042
- properties: node.properties || {},
3043
- states: node.states || [],
3044
- lifecycle: node.lifecycle || []
3112
+ entity: ni(node),
3113
+ properties: np(node),
3114
+ states: ns(node),
3115
+ lifecycle: nl(node)
3045
3116
  }) }] } };
3046
3117
  }
3047
3118
  return { jsonrpc: "2.0", id, error: { code: 404, message: `Unknown tool: ${name}` } };
@@ -3071,7 +3142,11 @@ function resolvePath2(p) {
3071
3142
  const resolved = p.startsWith("/") ? p : join2(process.cwd(), p);
3072
3143
  if (!p.startsWith("/") && !p.startsWith("~")) {
3073
3144
  const rel = resolve2(process.cwd(), p);
3074
- if (!rel.startsWith(process.cwd() + "/") && rel !== process.cwd()) {
3145
+ const cwd = process.cwd();
3146
+ const isDescendant = rel.startsWith(cwd + "/");
3147
+ const isSelf = rel === cwd;
3148
+ const isAncestor = cwd.startsWith(rel + "/");
3149
+ if (!isDescendant && !isSelf && !isAncestor) {
3075
3150
  throw new Error(`Path traversal blocked: ${p}`);
3076
3151
  }
3077
3152
  return rel;
@@ -3122,7 +3197,9 @@ program2.command("validate [dir]").action((d = ".") => {
3122
3197
  const projects = readdirSync2(dd, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
3123
3198
  for (const p of projects) {
3124
3199
  found = true;
3125
- const pd = join2(dd, p, "specs");
3200
+ const pd = join2(dd, p);
3201
+ if (!existsSync2(join2(pd, "SPEC.dog")))
3202
+ continue;
3126
3203
  const files = existsSync2(pd) ? readdirSync2(pd).filter((f) => f.endsWith(".dog")) : [];
3127
3204
  const missing = ["SPEC.dog", "constitution.dog", "data-model.dog"].filter((f) => !files.includes(f));
3128
3205
  const optional = ["COPY.dog", "plan.dog", "DESIGN-SYSTEM.dog", "INDEX.dog"].filter((f) => !files.includes(f));
@@ -3139,10 +3216,10 @@ program2.command("validate [dir]").action((d = ".") => {
3139
3216
  if (!found)
3140
3217
  console.log(source_default.yellow("No projects found. Run: spec init <project>"));
3141
3218
  });
3142
- program2.command("init <project>").action((p) => {
3219
+ program2.command("init <project>").option("-m, --minimal", "Only SPEC.dog + data-model.dog").action((p, opts) => {
3143
3220
  const d = join2(process.cwd(), "specs", p);
3144
3221
  mkdirSync(d, { recursive: true });
3145
- const tmpl = {
3222
+ const full = {
3146
3223
  "SPEC.dog": `# Project
3147
3224
 
3148
3225
  ## Product
@@ -3174,6 +3251,19 @@ program2.command("init <project>").action((p) => {
3174
3251
  |---|---|---|
3175
3252
  `
3176
3253
  };
3254
+ const minimal = {
3255
+ "SPEC.dog": `# Project
3256
+
3257
+ ## Product
3258
+
3259
+ `,
3260
+ "data-model.dog": `# Data Model
3261
+
3262
+ ## Entities
3263
+
3264
+ `
3265
+ };
3266
+ const tmpl = opts.minimal ? minimal : full;
3177
3267
  for (const [f, c] of Object.entries(tmpl)) {
3178
3268
  writeFileSync(join2(d, f), c);
3179
3269
  console.log(source_default.green(` ✓ ${f}`));
@@ -3192,7 +3282,7 @@ program2.command("list").action(() => {
3192
3282
  console.log(source_default.bold(`
3193
3283
  ${d}/`));
3194
3284
  for (const p of projects) {
3195
- const sp = join2(dd, p, "specs");
3285
+ const sp = join2(dd, p);
3196
3286
  const n = existsSync2(sp) ? readdirSync2(sp).filter((f) => f.endsWith(".dog")).length : 0;
3197
3287
  console.log(` ${source_default.cyan(p)} : ${n} .dog files`);
3198
3288
  }
@@ -3215,7 +3305,9 @@ program2.command("compile [dir]").option("-o, --output <file>").action((d = ".",
3215
3305
  continue;
3216
3306
  const projects = readdirSync2(dd, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
3217
3307
  for (const p of projects) {
3218
- const pd = join2(dd, p, "specs");
3308
+ const pd = join2(dd, p);
3309
+ if (!existsSync2(join2(pd, "SPEC.dog")))
3310
+ continue;
3219
3311
  if (!existsSync2(pd))
3220
3312
  continue;
3221
3313
  const files = readdirSync2(pd).filter((f) => f.endsWith(".dog")).sort();
@@ -3223,64 +3315,139 @@ program2.command("compile [dir]").option("-o, --output <file>").action((d = ".",
3223
3315
  continue;
3224
3316
  found = true;
3225
3317
  const sources = {};
3226
- let sourceBytes = 0;
3227
- const hash = createHash("sha256");
3318
+ let sourceBytes = 0, contentBytes = 0;
3228
3319
  for (const f of files) {
3229
3320
  const content = readFileSync2(join2(pd, f), "utf-8");
3230
3321
  sources[f] = content;
3231
- sourceBytes += Buffer.byteLength(content, "utf-8");
3232
- hash.update(content);
3233
- hash.update(`
3234
- `);
3322
+ const bytes = Buffer.byteLength(content, "utf-8");
3323
+ sourceBytes += bytes;
3324
+ if (bytes >= 100)
3325
+ contentBytes += bytes;
3235
3326
  }
3236
- const integrity = { sha256: hash.digest("hex"), source_files: files.length, source_bytes: sourceBytes };
3237
3327
  const sourceTokens = Math.round(sourceBytes / 4);
3328
+ const contentTokens = Math.round(contentBytes / 4);
3238
3329
  const nodes = [], edges = [];
3239
3330
  for (const f of files) {
3240
3331
  const ast = parse(sources[f]);
3241
3332
  for (const section of ast.sections) {
3242
3333
  for (const block of section.blocks) {
3243
- if (block.kind === "entity") {
3334
+ if (block.kind === "entity" || block.kind === "event") {
3335
+ const compactProps = {};
3336
+ for (const [key, val] of Object.entries(block.properties || {})) {
3337
+ let enc = "";
3338
+ const t = val.type || "string";
3339
+ if (t === "string")
3340
+ enc = "s";
3341
+ else if (t === "number")
3342
+ enc = "n";
3343
+ else if (t === "boolean")
3344
+ enc = "b";
3345
+ else if (t === "enum")
3346
+ enc = "e";
3347
+ else if (t === "json")
3348
+ enc = "j";
3349
+ else
3350
+ enc = t[0];
3351
+ if (val.required)
3352
+ enc += "!";
3353
+ compactProps[key] = enc;
3354
+ }
3355
+ nodes.push({
3356
+ i: block.name || "",
3357
+ t: block.type || "",
3358
+ g: block.kind,
3359
+ d: block.description || "",
3360
+ p: compactProps,
3361
+ s: block.states || [],
3362
+ l: block.lifecycle || []
3363
+ });
3364
+ }
3365
+ if (block.kind === "prediction") {
3244
3366
  nodes.push({
3245
- id: block.name,
3246
- type: block.type,
3247
- description: block.description || "",
3248
- file: f,
3249
- properties: Object.keys(block.properties).length,
3250
- states: block.states || [],
3251
- chars: section.content?.length || 0
3367
+ i: block.statement || block.name || "",
3368
+ t: "prediction",
3369
+ g: "prediction",
3370
+ d: block.description || "",
3371
+ p: {},
3372
+ s: [],
3373
+ l: [],
3374
+ cf: block.confidence || 0,
3375
+ tf: block.timeframe || "",
3376
+ tg: block.trigger || "",
3377
+ ms: block.measurement || ""
3252
3378
  });
3253
3379
  }
3254
3380
  if (block.kind === "relationship") {
3255
3381
  edges.push({
3256
- source: block.source,
3257
- target: block.target,
3258
- verb: block.verb,
3259
- cardinality: block.cardinality,
3260
- required: block.required,
3261
- file: f
3382
+ s: block.source,
3383
+ t: block.target,
3384
+ v: block.verb,
3385
+ d: block.description || "",
3386
+ c: block.cardinality,
3387
+ r: block.required
3262
3388
  });
3263
3389
  }
3264
3390
  }
3265
3391
  }
3266
3392
  }
3267
- const dag = { version: "1.2", project: p, compiled_at: new Date().toISOString(), compiler: `dotdog@${pkg.version}`, integrity, nodes, edges };
3393
+ const dag = { v: "1.4", p, c: `dotdog@${pkg.version}`, n: nodes, e: edges };
3268
3394
  const dagJson = JSON.stringify(dag);
3269
3395
  const dagTokens = Math.round(Buffer.byteLength(dagJson, "utf-8") / 4);
3270
- const savingsPct = sourceTokens > 0 ? Math.round((1 - dagTokens / sourceTokens) * 1000) / 10 : 0;
3396
+ const allSavingsPct = sourceTokens > 0 ? Math.round((1 - dagTokens / sourceTokens) * 1000) / 10 : 0;
3397
+ const contentSavingsPct = contentTokens > 0 ? Math.round((1 - dagTokens / contentTokens) * 1000) / 10 : 0;
3271
3398
  const savingsTokens = sourceTokens - dagTokens;
3272
- const outPath = opts.output || join2(pd, "..", `${p}.dag`);
3273
- const report = { ...dag, tokens: { source_total: sourceTokens, dag_total: dagTokens, savings_pct: savingsPct, savings_tokens: savingsTokens } };
3399
+ const outPath = opts.output || join2(pd, `${p}.dag`);
3400
+ const tokens = { m: "chars/4", st: sourceTokens, ct: contentTokens, dt: dagTokens, sv: allSavingsPct, cs: contentSavingsPct, saved: savingsTokens };
3401
+ const report = { ...dag, tk: tokens };
3274
3402
  writeFileSync(outPath, JSON.stringify(report, null, 2));
3275
3403
  console.log(source_default.green(` ✓ ${outPath}`));
3276
3404
  console.log(source_default.gray(` ${nodes.length} nodes, ${edges.length} edges, ${files.length} files`));
3277
- console.log(source_default.gray(` ${sourceTokens} → ${dagTokens} tokens (${savingsPct}% savings, ${savingsTokens} tokens saved)`));
3278
- console.log(source_default.gray(` sha256: ${integrity.sha256.substring(0, 12)}...`));
3405
+ console.log(source_default.gray(` ${sourceTokens} → ${dagTokens} tokens (${allSavingsPct}% savings, ${contentSavingsPct}% content-only, ${savingsTokens} saved)`));
3279
3406
  }
3280
3407
  }
3281
3408
  if (!found)
3282
3409
  console.log(source_default.yellow("No projects found."));
3283
3410
  });
3411
+ program2.command("tokens [dir]").action((d = ".") => {
3412
+ const dir = resolvePath2(d);
3413
+ const dirs = [join2(dir, "projects"), join2(dir, "specs"), dir];
3414
+ let found = false;
3415
+ for (const dd of dirs) {
3416
+ if (!existsSync2(dd))
3417
+ continue;
3418
+ const projects = readdirSync2(dd, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
3419
+ for (const p of projects) {
3420
+ const pd = join2(dd, p);
3421
+ if (!existsSync2(join2(pd, "SPEC.dog")))
3422
+ continue;
3423
+ const dagFile = join2(pd, `${p}.dag`);
3424
+ if (!existsSync2(dagFile))
3425
+ continue;
3426
+ found = true;
3427
+ const dogFiles = readdirSync2(pd).filter((f) => f.endsWith(".dog"));
3428
+ let sourceBytes = 0, contentBytes = 0;
3429
+ for (const f of dogFiles) {
3430
+ const bytes = Buffer.byteLength(readFileSync2(join2(pd, f), "utf-8"), "utf-8");
3431
+ sourceBytes += bytes;
3432
+ if (bytes >= 100)
3433
+ contentBytes += bytes;
3434
+ }
3435
+ const dagBytes = Buffer.byteLength(readFileSync2(dagFile, "utf-8"), "utf-8");
3436
+ const savings = sourceBytes > 0 ? Math.round((1 - dagBytes / sourceBytes) * 1000) / 10 : 0;
3437
+ console.log(source_default.bold(`
3438
+ ${p}`));
3439
+ console.log(source_default.gray(` ${dogFiles.length} .dog files: ${sourceBytes} bytes`));
3440
+ console.log(source_default.gray(` .dag file: ${dagBytes} bytes`));
3441
+ console.log(source_default.green(` ${savings}% smaller (${sourceBytes - dagBytes} bytes saved)`));
3442
+ if (contentBytes && contentBytes !== sourceBytes) {
3443
+ const cs = Math.round((1 - dagBytes / contentBytes) * 1000) / 10;
3444
+ console.log(source_default.gray(` content-only: ${contentBytes} bytes → ${cs}% savings`));
3445
+ }
3446
+ }
3447
+ }
3448
+ if (!found)
3449
+ console.log(source_default.yellow("No .dag files found. Run compile first."));
3450
+ });
3284
3451
  program2.command("visualize [dir]").option("-s, --save").action((d = ".", opts) => {
3285
3452
  const dir = resolvePath2(d);
3286
3453
  const dirs = [join2(dir, "projects"), join2(dir, "specs"), dir];
@@ -3327,7 +3494,9 @@ Spec Analysis
3327
3494
  for (const p of projects) {
3328
3495
  if (opts.project && p !== opts.project)
3329
3496
  continue;
3330
- const pd = join2(dd, p, "specs");
3497
+ const pd = join2(dd, p);
3498
+ if (!existsSync2(join2(pd, "SPEC.dog")))
3499
+ continue;
3331
3500
  if (!existsSync2(pd))
3332
3501
  continue;
3333
3502
  const files = readdirSync2(pd).filter((f) => f.endsWith(".dog"));
@@ -3407,7 +3576,7 @@ program2.command("generate [dir]").description("Generate missing spec files from
3407
3576
  for (const p of projects) {
3408
3577
  if (opts.project && p !== opts.project)
3409
3578
  continue;
3410
- const pd = join2(dd, p, "specs");
3579
+ const pd = join2(dd, p);
3411
3580
  const sp = join2(pd, "SPEC.dog");
3412
3581
  if (existsSync2(sp)) {
3413
3582
  specContent = readFileSync2(sp, "utf-8");
@@ -3525,7 +3694,9 @@ program2.command("staleness [dir]").action((d = ".") => {
3525
3694
  continue;
3526
3695
  const projects = readdirSync2(dd, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
3527
3696
  for (const p of projects) {
3528
- const pd = join2(dd, p, "specs");
3697
+ const pd = join2(dd, p);
3698
+ if (!existsSync2(join2(pd, "SPEC.dog")))
3699
+ continue;
3529
3700
  if (!existsSync2(pd))
3530
3701
  continue;
3531
3702
  const planFile = join2(pd, "plan.dog");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotdog",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "CLI tool for structured software specifications. Validate .dog files, compile .dag graphs, query via MCP.",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",
@@ -15,29 +15,25 @@
15
15
  "LICENSE"
16
16
  ],
17
17
  "keywords": [
18
+ "spec",
18
19
  "specification",
19
- "cli",
20
+ "ai",
21
+ "agent",
20
22
  "mcp",
23
+ "documentation",
24
+ "cli",
25
+ "dogfood",
26
+ "spec-driven-development",
21
27
  "graph",
22
- "dag",
23
- "yaml",
24
28
  "markdown",
25
- "spec-driven-development"
29
+ "yaml",
30
+ "dotdog",
31
+ "dag",
32
+ "dog"
26
33
  ],
27
34
  "license": "MIT",
28
35
  "author": "specdog",
29
- "homepage": "https://github.com/specdog/dotdog#readme",
30
- "bugs": {
31
- "url": "https://github.com/specdog/dotdog/issues"
32
- },
33
- "repository": {
34
- "type": "git",
35
- "url": "https://github.com/specdog/dotdog.git",
36
- "directory": "packages/dotdog"
37
- },
38
- "engines": {
39
- "node": ">=20"
40
- },
36
+ "repository": "github:specdog/dotdog",
41
37
  "dependencies": {
42
38
  "commander": "^15.0.0",
43
39
  "chalk": "^5.6.0"
package/CHANGELOG.md DELETED
@@ -1,35 +0,0 @@
1
- # Changelog
2
-
3
- ## 0.3.1
4
-
5
- - Fix npm packaging: homepage, bugs, engines, repository fields
6
- - Trim keywords to 8 specific terms
7
- - Ship correct README, LICENSE, and CHANGELOG in package
8
- - CLI help text uses colons, no em dashes
9
-
10
- ## 0.3.0
11
-
12
- - `dotdog analyze` — deep project analysis: score, gaps, entity audit
13
- - `dotdog generate` — generate missing spec files from SPEC.dog
14
- - `dotdog simulate` — run simulation scenarios
15
- - `.dag` v1.2: provable token savings, typed nodes and edges
16
- - VS Code extension: syntax highlighting for .dog files
17
- - GitHub Pages and llms.txt for AI agent discoverability
18
- - `dotdog serve` MCP server: getEntity, traverse, search, schema, summary
19
- - Path traversal guard, traverse depth cap, serve hardening
20
-
21
- ## 0.2.0
22
-
23
- - `dotdog compile` — compile .dog files to .dag graph
24
- - `dotdog visualize` — Mermaid graph output with --save flag
25
- - MCP server: expose .dag graph to AI agents over stdio
26
- - `dotdog staleness` — detect drift between spec and reality
27
- - `dotdog init` — scaffold new spec genome projects
28
- - `dotdog list` — list all projects and .dog file counts
29
-
30
- ## 0.1.0
31
-
32
- - `.dog` file format v1.0
33
- - `.dag` file format v1.0
34
- - `dotdog validate` — validate spec completeness
35
- - `dotdog parse` — parse .dog files
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 specdog
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.