@jenga-ai/agent 1.0.0

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 (177) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +340 -0
  3. package/agents/ai_engineer.md +113 -0
  4. package/agents/developer.md +236 -0
  5. package/agents/scrum-master.md +349 -0
  6. package/agents/scrutiny-agent.md +137 -0
  7. package/agents/solution-assessor.md +185 -0
  8. package/agents/tester.md +339 -0
  9. package/bin/jenga.js +70 -0
  10. package/hooks/copilot_session_end.sh +29 -0
  11. package/hooks/on_session_end.sh +238 -0
  12. package/hooks/prompt_router.sh +11 -0
  13. package/hooks/prompt_router_helper.js +52 -0
  14. package/hooks/session_end_helper.js +29 -0
  15. package/hooks/session_end_watcher.sh +24 -0
  16. package/lib/commands/attach.js +47 -0
  17. package/lib/commands/init.js +207 -0
  18. package/lib/commands/start.js +16 -0
  19. package/lib/commands/status.js +53 -0
  20. package/lib/config-schema.js +72 -0
  21. package/lib/inject-settings.js +61 -0
  22. package/lib/mirror.js +244 -0
  23. package/lib/resolve-project-dir.sh +47 -0
  24. package/mcp/execute-ticket/index.js +10 -0
  25. package/mcp/execute-ticket/package.json +5 -0
  26. package/mcp/help/index.js +79 -0
  27. package/mcp/help/package.json +14 -0
  28. package/mcp/router/README.md +19 -0
  29. package/mcp/router/embedder.js +23 -0
  30. package/mcp/router/index.js +204 -0
  31. package/mcp/router/matcher.js +87 -0
  32. package/mcp/router/package-lock.json +1048 -0
  33. package/mcp/router/package.json +11 -0
  34. package/mcp/router/skill-index.js +104 -0
  35. package/package.json +47 -0
  36. package/scripts/board_resolver.sh +46 -0
  37. package/scripts/e25_s01_extract_board_graph.py +292 -0
  38. package/scripts/e25_s01_generate_synthetic_board.py +90 -0
  39. package/scripts/measurement-10x.json +50 -0
  40. package/scripts/measurement-10x.txt +4 -0
  41. package/scripts/measurement-real.json +50 -0
  42. package/scripts/measurement-real.txt +4 -0
  43. package/scripts/postinstall.js +165 -0
  44. package/scripts/todo_cleanup.sh +22 -0
  45. package/scripts/todo_manager.sh +86 -0
  46. package/scripts/validate-board.sh +190 -0
  47. package/scripts/validate-story-format.sh +53 -0
  48. package/skills/brainstorm/SKILL.md +47 -0
  49. package/skills/btw/SKILL.md +42 -0
  50. package/skills/commit/SKILL.md +29 -0
  51. package/skills/commit/assets/user_instructions_template.md +22 -0
  52. package/skills/continue/SKILL.md +29 -0
  53. package/skills/convert/SKILL.md +124 -0
  54. package/skills/convert/convert_cli.py +235 -0
  55. package/skills/convert/tests/sample.csv +4 -0
  56. package/skills/convert/tests/sample.json +5 -0
  57. package/skills/convert/tests/sample.jsonl +3 -0
  58. package/skills/convert/tests/sample.yaml +18 -0
  59. package/skills/convert/tests/sample_obj.csv +2 -0
  60. package/skills/convert/tests/sample_obj.json +9 -0
  61. package/skills/deep-dive/SKILL.md +167 -0
  62. package/skills/do/SKILL.md +88 -0
  63. package/skills/do/assets/sender_template.json +12 -0
  64. package/skills/doc/SKILL.md +314 -0
  65. package/skills/doc/assets/path-objectives.yaml +38 -0
  66. package/skills/doc-sync/SKILL.md +167 -0
  67. package/skills/doc-sync/assets/default_excludes.txt +21 -0
  68. package/skills/doc-sync/assets/doc_targets.md +14 -0
  69. package/skills/dooo/SKILL.md +60 -0
  70. package/skills/error/SKILL.md +29 -0
  71. package/skills/evaluate/SKILL.md +45 -0
  72. package/skills/evaluate/assets/evaluation_invokation_template.yml +3 -0
  73. package/skills/evaluate/assets/evaluation_rapport_template.md +24 -0
  74. package/skills/examplify/SKILL.md +42 -0
  75. package/skills/help/SKILL.md +36 -0
  76. package/skills/improve/SKILL.md +55 -0
  77. package/skills/index/scripts/board-index +4 -0
  78. package/skills/index/scripts/board_index.py +615 -0
  79. package/skills/index/scripts/smoke_test.sh +86 -0
  80. package/skills/init/SKILL.md +44 -0
  81. package/skills/init/assets/.gitignore_template +15 -0
  82. package/skills/init/assets/PROJECT_SUMMARY_template.md +13 -0
  83. package/skills/init/assets/directory_structure.txt +13 -0
  84. package/skills/init/assets/test-config_template.json +4 -0
  85. package/skills/init/assets/workflow_template.json +30 -0
  86. package/skills/init/scripts/init.sh +48 -0
  87. package/skills/jbp/SKILL.md +25 -0
  88. package/skills/jenga/SKILL.md +68 -0
  89. package/skills/lgtm/SKILL.md +21 -0
  90. package/skills/mirror-public/SKILL.md +237 -0
  91. package/skills/mirror-public/assets/config.json +5 -0
  92. package/skills/mirror-public/scripts/mirror.sh +374 -0
  93. package/skills/pi-plan/SKILL.md +62 -0
  94. package/skills/pi-plan/assets/epic.json +7 -0
  95. package/skills/pi-plan/assets/story_template.md +18 -0
  96. package/skills/proceed/SKILL.md +29 -0
  97. package/skills/publish/SKILL.md +351 -0
  98. package/skills/publish/adapters/droplet.md +200 -0
  99. package/skills/publish/adapters/mobile-ios.md +114 -0
  100. package/skills/publish/adapters/npm-ci.md +223 -0
  101. package/skills/publish/adapters/npm.md +121 -0
  102. package/skills/publish/assets/ExportOptions.plist.template +19 -0
  103. package/skills/publish/assets/ci-contract.md +111 -0
  104. package/skills/publish/assets/ownership-matrix.md +17 -0
  105. package/skills/publish/assets/publish.example.json +85 -0
  106. package/skills/publish/assets/publish.example.npm-ci.json +40 -0
  107. package/skills/publish/assets/publish.example.npm.json +41 -0
  108. package/skills/publish/assets/secrets-guide.md +104 -0
  109. package/skills/publish/schemas/fixtures/npm-ci-minimal.json +17 -0
  110. package/skills/publish/schemas/fixtures/npm-ci-with-empty-secrets.json +18 -0
  111. package/skills/publish/schemas/fixtures/npm-ci-with-workflow-path.json +18 -0
  112. package/skills/publish/schemas/publish.schema.json +428 -0
  113. package/skills/publish/scripts/check_target_config.sh +96 -0
  114. package/skills/publish/scripts/droplet_pipeline.sh +208 -0
  115. package/skills/publish/scripts/generate_release_notes.sh +200 -0
  116. package/skills/publish/scripts/ios_pipeline.sh +486 -0
  117. package/skills/publish/scripts/npm_ci_pipeline.sh +225 -0
  118. package/skills/publish/scripts/npm_pipeline.sh +249 -0
  119. package/skills/publish/scripts/publish_common.sh +253 -0
  120. package/skills/publish/scripts/publish_deploy.sh +538 -0
  121. package/skills/publish/scripts/reconcile_tags.sh +135 -0
  122. package/skills/publish/scripts/run_gates.sh +616 -0
  123. package/skills/publish/scripts/setup_wizard.sh +394 -0
  124. package/skills/publish/scripts/show_history.sh +95 -0
  125. package/skills/publish/scripts/suggest_semver_bump.sh +105 -0
  126. package/skills/publish/scripts/validate_config.sh +163 -0
  127. package/skills/publish/scripts/validate_droplet_env.sh +45 -0
  128. package/skills/publish/scripts/validate_ios_env.sh +68 -0
  129. package/skills/publish/scripts/validate_npm_ci_env.sh +71 -0
  130. package/skills/publish/scripts/validate_npm_env.sh +22 -0
  131. package/skills/publish/scripts/write_ledger_entry.sh +126 -0
  132. package/skills/publish/wizards/droplet.md +275 -0
  133. package/skills/publish/wizards/mobile-ios.md +157 -0
  134. package/skills/publish/wizards/npm-ci.md +240 -0
  135. package/skills/publish/wizards/npm.md +224 -0
  136. package/skills/reconcile/SKILL.md +93 -0
  137. package/skills/reconcile/assets/report_format.md +44 -0
  138. package/skills/reconcile-origin/SKILL.md +75 -0
  139. package/skills/reconcile-origin/scripts/reconcile-origin.sh +372 -0
  140. package/skills/redo/SKILL.md +70 -0
  141. package/skills/route/SKILL.md +180 -0
  142. package/skills/self-sync/SKILL.md +73 -0
  143. package/skills/self-sync/scripts/run.js +136 -0
  144. package/skills/skillify/SKILL.md +68 -0
  145. package/skills/skillify/assets/init-new/SKILL.md +35 -0
  146. package/skills/skillify/assets/init-new/assets/.gitignore_template +15 -0
  147. package/skills/skillify/assets/init-new/assets/PROJECT_SUMMARY_template.md +13 -0
  148. package/skills/skillify/assets/init-new/assets/directory_structure.txt +10 -0
  149. package/skills/skillify/assets/init-new/assets/test-config_template.json +4 -0
  150. package/skills/skillify/assets/init-new/assets/workflow_template.json +17 -0
  151. package/skills/skillify/assets/init-new/scripts/init.sh +48 -0
  152. package/skills/skillify/assets/init-old/SKILL.md +124 -0
  153. package/skills/spinoff/SKILL.md +48 -0
  154. package/skills/status/SKILL.md +33 -0
  155. package/skills/status/assets/output_format.md +41 -0
  156. package/skills/todo/SKILL.md +46 -0
  157. package/skills/todo/assets/todo_handoff_template.md +22 -0
  158. package/skills/todo/assets/todo_template.md +3 -0
  159. package/skills/train/SKILL.md +116 -0
  160. package/skills/train/assets/dashboard-templates/classifiers.html +106 -0
  161. package/skills/train/assets/dashboard-templates/nlp.html +102 -0
  162. package/skills/train/assets/dashboard-templates/transformers.html +98 -0
  163. package/skills/train/assets/results-parsers/__init__.py +9 -0
  164. package/skills/train/assets/results-parsers/classifiers.py +84 -0
  165. package/skills/train/assets/results-parsers/nlp.py +88 -0
  166. package/skills/train/assets/results-parsers/reporter.py +154 -0
  167. package/skills/train/assets/results-parsers/transformers.py +120 -0
  168. package/skills/train/train_cli.py +786 -0
  169. package/templates/EXECUTION_PLAN_TEMPLATE.md +43 -0
  170. package/templates/EXECUTION_SUMMARY_TEMPLATE.md +50 -0
  171. package/templates/JENGA_CONFIG_TEMPLATE.json +23 -0
  172. package/templates/PROBLEM_RAPPORT_TEMPLATE.md +88 -0
  173. package/templates/SCRUM_BOARD_SCHEMA.md +311 -0
  174. package/templates/SKILL.md +16 -0
  175. package/templates/SKILL_TEMPLATE.md +28 -0
  176. package/templates/USER_INSTRUCTIONS_TEMPLATE.md +22 -0
  177. package/templates/copilot-instructions.md.tpl +55 -0
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "jenga-router",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "main": "index.js",
6
+ "description": "Jenga Router — stdio MCP server for skill-based prompt routing",
7
+ "dependencies": {
8
+ "@modelcontextprotocol/sdk": "^1.11.1",
9
+ "zod": "^3.24.4"
10
+ }
11
+ }
@@ -0,0 +1,104 @@
1
+ import { readdirSync, readFileSync, statSync, existsSync } from "fs";
2
+ import { join } from "path";
3
+
4
+ /**
5
+ * Parses YAML frontmatter from a markdown file.
6
+ * Returns an object with the parsed fields, or {} if no frontmatter found.
7
+ */
8
+ function parseFrontmatter(content) {
9
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
10
+ if (!match) return {};
11
+ try {
12
+ const lines = match[1].split("\n");
13
+ const obj = {};
14
+ let currentKey = null;
15
+ let arrayMode = false;
16
+ for (const line of lines) {
17
+ const arrayItem = line.match(/^\s+-\s+(.*)/);
18
+ if (arrayItem && arrayMode && currentKey) {
19
+ obj[currentKey].push(arrayItem[1].trim().replace(/^["']|["']$/g, ""));
20
+ continue;
21
+ }
22
+ arrayMode = false;
23
+ const kv = line.match(/^(\w+):\s*(.*)/);
24
+ if (!kv) continue;
25
+ const [, key, val] = kv;
26
+ if (val.trim() === "[]") {
27
+ obj[key] = [];
28
+ currentKey = key;
29
+ arrayMode = false;
30
+ } else if (val.trim() === "") {
31
+ obj[key] = [];
32
+ currentKey = key;
33
+ arrayMode = true;
34
+ } else {
35
+ obj[key] = val.trim().replace(/^["']|["']$/g, "");
36
+ currentKey = key;
37
+ }
38
+ }
39
+ return obj;
40
+ } catch {
41
+ return {};
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Recursively finds all SKILL.md files under a directory.
47
+ */
48
+ function findSkillFiles(dir) {
49
+ if (!existsSync(dir)) return [];
50
+ const results = [];
51
+ try {
52
+ for (const entry of readdirSync(dir)) {
53
+ const full = join(dir, entry);
54
+ try {
55
+ if (statSync(full).isDirectory()) {
56
+ results.push(...findSkillFiles(full));
57
+ } else if (entry === "SKILL.md") {
58
+ results.push(full);
59
+ }
60
+ } catch {}
61
+ }
62
+ } catch {}
63
+ return results;
64
+ }
65
+
66
+ /**
67
+ * Builds the in-memory skill index from the skills directory.
68
+ * Optionally accepts an embedder ({ embed(text) }) to pre-compute skill embeddings.
69
+ * Returns an array of skill records.
70
+ */
71
+ export async function buildSkillIndex(skillsPath, embedder = null) {
72
+ const start = Date.now();
73
+ const files = findSkillFiles(skillsPath);
74
+ const skills = [];
75
+
76
+ for (const filePath of files) {
77
+ try {
78
+ const content = readFileSync(filePath, "utf8");
79
+ const fm = parseFrontmatter(content);
80
+ if (!fm.name) {
81
+ process.stderr.write(`Warning: ${filePath} has no 'name' in frontmatter — skipped\n`);
82
+ continue;
83
+ }
84
+ const skill = {
85
+ name: fm.name,
86
+ description: fm.description || "",
87
+ keywords: Array.isArray(fm.keywords) ? fm.keywords : [],
88
+ examples: Array.isArray(fm.examples) ? fm.examples : [],
89
+ path: filePath,
90
+ embedding: null,
91
+ };
92
+ if (embedder) {
93
+ skill.embedding = await embedder.embed(skill.description);
94
+ }
95
+ skills.push(skill);
96
+ } catch (e) {
97
+ process.stderr.write(`Warning: failed to parse ${filePath}: ${e.message}\n`);
98
+ }
99
+ }
100
+
101
+ const elapsed = Date.now() - start;
102
+ process.stderr.write(`[jenga-router] Indexed ${skills.length} skills in ${elapsed}ms\n`);
103
+ return skills;
104
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@jenga-ai/agent",
3
+ "version": "1.0.0",
4
+ "description": "JengaAgent — agentic project management CLI",
5
+ "type": "module",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "bin": {
10
+ "jenga": "./bin/jenga.js"
11
+ },
12
+ "scripts": {
13
+ "postinstall": "node scripts/postinstall.js",
14
+ "ui:dev": "npm run ui:dev --prefix project/app",
15
+ "ui:build": "npm run ui:build --prefix project/app",
16
+ "api:start": "npm run api:start --prefix project/app",
17
+ "api:dev": "npm run api:dev --prefix project/app",
18
+ "dashboard:start": "npm run dashboard:start --prefix project/app",
19
+ "dashboard:open": "npm run dashboard:open --prefix project/app"
20
+ },
21
+ "files": [
22
+ "skills/",
23
+ "agents/",
24
+ "hooks/",
25
+ "scripts/",
26
+ "templates/",
27
+ "bin/",
28
+ "lib/",
29
+ "mcp/execute-ticket/",
30
+ "mcp/help/",
31
+ "mcp/router/index.js",
32
+ "mcp/router/matcher.js",
33
+ "mcp/router/embedder.js",
34
+ "mcp/router/skill-index.js",
35
+ "mcp/router/package.json",
36
+ "mcp/router/package-lock.json",
37
+ "mcp/router/README.md",
38
+ "README.md",
39
+ "LICENSE"
40
+ ],
41
+ "keywords": [],
42
+ "author": "",
43
+ "license": "MIT",
44
+ "dependencies": {
45
+ "@xenova/transformers": "^2.17.2"
46
+ }
47
+ }
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env bash
2
+ # Resolves the board path from project/configs/workflow.json.
3
+ # Outputs a normalised path (always ends with exactly one '/') to stdout.
4
+ # Falls back to "project/board/" when the config file is missing or the key is absent.
5
+ # Exits non-zero and prints to stderr when the config file exists but is malformed JSON.
6
+
7
+ CONFIG="project/configs/workflow.json"
8
+ DEFAULT="project/board/"
9
+
10
+ if [ ! -f "$CONFIG" ]; then
11
+ printf '%s\n' "$DEFAULT"
12
+ exit 0
13
+ fi
14
+
15
+ if command -v jq >/dev/null 2>&1; then
16
+ # Validate JSON first
17
+ if ! jq empty "$CONFIG" 2>/dev/null; then
18
+ echo "Error: $CONFIG contains malformed JSON" >&2
19
+ exit 1
20
+ fi
21
+
22
+ board_path=$(jq -r '.paths.board // empty' "$CONFIG" 2>/dev/null)
23
+ else
24
+ # Validate JSON portably: check that the file starts with '{' or '[' after whitespace
25
+ first_char=$(sed 's/^[[:space:]]*//' "$CONFIG" | head -c1)
26
+ if [ "$first_char" != "{" ] && [ "$first_char" != "[" ]; then
27
+ echo "Error: $CONFIG contains malformed JSON" >&2
28
+ exit 1
29
+ fi
30
+
31
+ # Portable grep+sed fallback: extract "board": "value"
32
+ board_path=$(grep -o '"board"[[:space:]]*:[[:space:]]*"[^"]*"' "$CONFIG" \
33
+ | sed 's/.*"board"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' \
34
+ | head -1)
35
+ fi
36
+
37
+ if [ -z "$board_path" ]; then
38
+ printf '%s\n' "$DEFAULT"
39
+ exit 0
40
+ fi
41
+
42
+ # Normalise: ensure exactly one trailing slash
43
+ board_path="${board_path%/}/"
44
+
45
+ printf '%s\n' "$board_path"
46
+ exit 0
@@ -0,0 +1,292 @@
1
+ #!/usr/bin/env python3
2
+ # THROWAWAY — spike prototype for E25_S01 latency measurement. Do not import.
3
+
4
+ import argparse
5
+ import hashlib
6
+ import json
7
+ import re
8
+ import sys
9
+ import time
10
+ from pathlib import Path
11
+
12
+ PREFIXED_REF = r"(?:[A-Z0-9]+_)?"
13
+ EPIC_ID_RE = re.compile(rf"^{PREFIXED_REF}E\d{{2}}$")
14
+ STORY_ID_RE = re.compile(rf"^{PREFIXED_REF}E\d{{2}}_S\d{{2}}$")
15
+ TASK_ID_RE = re.compile(rf"^{PREFIXED_REF}E\d{{2}}_S\d{{2}}_T\d{{2}}$")
16
+ TARGET_REF_RE = re.compile(rf"^({PREFIXED_REF}E\d{{2}}_S\d{{2}}(?:_T\d{{2}})?)-(plan|summary)\.md$")
17
+ TODO_REF_RE = re.compile(rf"\b({PREFIXED_REF}E\d{{2}}_S\d{{2}}(?:_T\d{{2}})?)\b")
18
+
19
+
20
+ def parse_args() -> argparse.Namespace:
21
+ parser = argparse.ArgumentParser(description="Throwaway board graph extractor for E25_S01.")
22
+ parser.add_argument("root", nargs="?", default="project", help="Project-like root containing board/, documentation/, and todo.md")
23
+ parser.add_argument("--profile", action="store_true", help="Include coarse timing breakdown in the output JSON")
24
+ return parser.parse_args()
25
+
26
+
27
+ def parse_scalar(value: str):
28
+ text = value.strip()
29
+ if text in {"", "null", "~"}:
30
+ return ""
31
+ if text.startswith("[") and text.endswith("]"):
32
+ inner = text[1:-1].strip()
33
+ if not inner:
34
+ return []
35
+ return [item.strip().strip("'\"") for item in inner.split(",") if item.strip()]
36
+ return text.strip("'\"")
37
+
38
+
39
+ def parse_frontmatter(path: Path) -> tuple[dict, str]:
40
+ text = path.read_text(encoding="utf-8")
41
+ if not text.startswith("---\n"):
42
+ return {}, text
43
+ parts = text.split("\n---\n", 1)
44
+ if len(parts) != 2:
45
+ return {}, text
46
+ raw, body = parts
47
+ lines = raw.splitlines()[1:]
48
+ data: dict[str, object] = {}
49
+ current_key = None
50
+ for line in lines:
51
+ if not line.strip():
52
+ continue
53
+ if line.startswith(" - ") and current_key:
54
+ data.setdefault(current_key, [])
55
+ cast = data[current_key]
56
+ if isinstance(cast, list):
57
+ cast.append(line[4:].strip().strip("'\""))
58
+ continue
59
+ if ":" not in line:
60
+ current_key = None
61
+ continue
62
+ key, value = line.split(":", 1)
63
+ key = key.strip()
64
+ parsed = parse_scalar(value)
65
+ data[key] = parsed
66
+ current_key = key if parsed == "" else None
67
+ return data, body
68
+
69
+
70
+ def relpath(path: Path, root: Path) -> str:
71
+ return path.relative_to(root).as_posix()
72
+
73
+
74
+ def node_sort_key(node: dict) -> tuple:
75
+ return (node.get("type", ""), node.get("id", ""), node.get("path", ""), node.get("line_number", 0))
76
+
77
+
78
+ def edge_sort_key(edge: dict) -> tuple:
79
+ return (edge.get("type", ""), edge.get("from", ""), edge.get("to", ""), edge.get("source", ""))
80
+
81
+
82
+ def rounded_timings(timings: dict[str, float]) -> dict[str, float]:
83
+ return {key: round(value, 3) for key, value in timings.items()}
84
+
85
+
86
+ def add_board_nodes(
87
+ root: Path,
88
+ collection: str,
89
+ node_type: str,
90
+ nodes: list[dict],
91
+ records: list[dict],
92
+ timings: dict[str, float],
93
+ ):
94
+ base = root / "board" / collection
95
+ walk_started = time.perf_counter()
96
+ paths = sorted(base.glob("*.md"))
97
+ timings["board_directory_walk_ms"] = timings.get("board_directory_walk_ms", 0.0) + ((time.perf_counter() - walk_started) * 1000)
98
+ for path in paths:
99
+ parse_started = time.perf_counter()
100
+ frontmatter, _ = parse_frontmatter(path)
101
+ timings["board_frontmatter_parse_ms"] = timings.get("board_frontmatter_parse_ms", 0.0) + ((time.perf_counter() - parse_started) * 1000)
102
+ node = {
103
+ "id": frontmatter.get("id") or path.stem.split("_", 1)[0],
104
+ "type": node_type,
105
+ "path": relpath(path, root),
106
+ "title": frontmatter.get("title", ""),
107
+ "status": frontmatter.get("status", ""),
108
+ }
109
+ if frontmatter.get("epic_id"):
110
+ node["epic_id"] = frontmatter["epic_id"]
111
+ if frontmatter.get("story_id"):
112
+ node["story_id"] = frontmatter["story_id"]
113
+ if isinstance(frontmatter.get("stories"), list):
114
+ node["stories"] = list(frontmatter["stories"])
115
+ if isinstance(frontmatter.get("tasks"), list):
116
+ node["tasks"] = list(frontmatter["tasks"])
117
+ nodes.append(node)
118
+ records.append({"node": node, "frontmatter": frontmatter})
119
+
120
+
121
+ def add_doc_nodes(
122
+ root: Path,
123
+ folder: str,
124
+ node_type: str,
125
+ nodes: list[dict],
126
+ records: list[dict],
127
+ timings: dict[str, float],
128
+ ):
129
+ base = root / "documentation" / folder
130
+ walk_started = time.perf_counter()
131
+ paths = sorted(base.glob("*.md"))
132
+ timings["doc_directory_walk_ms"] = timings.get("doc_directory_walk_ms", 0.0) + ((time.perf_counter() - walk_started) * 1000)
133
+ for path in paths:
134
+ parse_started = time.perf_counter()
135
+ frontmatter, _ = parse_frontmatter(path)
136
+ timings["doc_frontmatter_parse_ms"] = timings.get("doc_frontmatter_parse_ms", 0.0) + ((time.perf_counter() - parse_started) * 1000)
137
+ stem = path.stem
138
+ node = {
139
+ "id": stem,
140
+ "type": node_type,
141
+ "path": relpath(path, root),
142
+ "title": frontmatter.get("title") or frontmatter.get("id") or stem,
143
+ }
144
+ match = TARGET_REF_RE.match(path.name)
145
+ if match:
146
+ node["target_ref"] = match.group(1)
147
+ nodes.append(node)
148
+ records.append({"node": node, "frontmatter": frontmatter})
149
+
150
+
151
+ def add_todo_nodes(root: Path, nodes: list[dict], records: list[dict], timings: dict[str, float]):
152
+ todo_path = root / "todo.md"
153
+ started = time.perf_counter()
154
+ for line_number, raw_line in enumerate(todo_path.read_text(encoding="utf-8").splitlines(), start=1):
155
+ line = raw_line.strip()
156
+ if not line or line.startswith("<!--"):
157
+ continue
158
+ if line.startswith("#"):
159
+ continue
160
+ if set(line) <= {"-"}:
161
+ continue
162
+ refs = TODO_REF_RE.findall(line)
163
+ digest = hashlib.sha1(line.encode("utf-8")).hexdigest()[:12]
164
+ node = {
165
+ "id": f"todo:{line_number}:{digest}",
166
+ "type": "TodoEntry",
167
+ "path": relpath(todo_path, root),
168
+ "line_number": line_number,
169
+ "text": line,
170
+ }
171
+ if refs:
172
+ node["board_refs"] = refs
173
+ nodes.append(node)
174
+ records.append({"node": node, "refs": refs})
175
+ timings["todo_parse_ms"] = timings.get("todo_parse_ms", 0.0) + ((time.perf_counter() - started) * 1000)
176
+
177
+
178
+ def target_type(target_id: str) -> str:
179
+ if TASK_ID_RE.match(target_id):
180
+ return "Task"
181
+ if STORY_ID_RE.match(target_id):
182
+ return "Story"
183
+ if EPIC_ID_RE.match(target_id):
184
+ return "Epic"
185
+ return "Unknown"
186
+
187
+
188
+ def main() -> int:
189
+ args = parse_args()
190
+ root = Path(args.root).resolve()
191
+ nodes: list[dict] = []
192
+ edges: list[dict] = []
193
+
194
+ started = time.perf_counter()
195
+ timings: dict[str, float] = {}
196
+
197
+ t0 = time.perf_counter()
198
+ epic_records: list[dict] = []
199
+ story_records: list[dict] = []
200
+ task_records: list[dict] = []
201
+ add_board_nodes(root, "epics", "Epic", nodes, epic_records, timings)
202
+ add_board_nodes(root, "stories", "Story", nodes, story_records, timings)
203
+ add_board_nodes(root, "tasks", "Task", nodes, task_records, timings)
204
+ plan_records: list[dict] = []
205
+ summary_records: list[dict] = []
206
+ add_doc_nodes(root, "plans", "Plan", nodes, plan_records, timings)
207
+ add_doc_nodes(root, "summaries", "Summary", nodes, summary_records, timings)
208
+ todo_records: list[dict] = []
209
+ add_todo_nodes(root, nodes, todo_records, timings)
210
+ timings["node_extraction_ms"] = round((time.perf_counter() - t0) * 1000, 3)
211
+
212
+ t0 = time.perf_counter()
213
+ known_ids = {node["id"] for node in nodes}
214
+ for record in epic_records:
215
+ source = record["node"]
216
+ for story_id in source.get("stories", []):
217
+ edges.append({
218
+ "type": "contains",
219
+ "from": source["id"],
220
+ "to": story_id,
221
+ "from_type": "Epic",
222
+ "to_type": target_type(story_id),
223
+ "source": source["path"],
224
+ })
225
+ for record in story_records:
226
+ source = record["node"]
227
+ for task_id in source.get("tasks", []):
228
+ edges.append({
229
+ "type": "contains",
230
+ "from": source["id"],
231
+ "to": task_id,
232
+ "from_type": "Story",
233
+ "to_type": target_type(task_id),
234
+ "source": source["path"],
235
+ })
236
+ for record in plan_records:
237
+ source = record["node"]
238
+ target = source.get("target_ref")
239
+ if target:
240
+ edges.append({
241
+ "type": "plans",
242
+ "from": source["id"],
243
+ "to": target,
244
+ "from_type": "Plan",
245
+ "to_type": target_type(target),
246
+ "source": source["path"],
247
+ })
248
+ for record in summary_records:
249
+ source = record["node"]
250
+ target = source.get("target_ref")
251
+ if target:
252
+ edges.append({
253
+ "type": "summarizes",
254
+ "from": source["id"],
255
+ "to": target,
256
+ "from_type": "Summary",
257
+ "to_type": target_type(target),
258
+ "source": source["path"],
259
+ })
260
+ for record in todo_records:
261
+ source = record["node"]
262
+ for ref in record.get("refs", []):
263
+ if ref in known_ids:
264
+ edges.append({
265
+ "type": "queued_as",
266
+ "from": source["id"],
267
+ "to": ref,
268
+ "from_type": "TodoEntry",
269
+ "to_type": target_type(ref),
270
+ "source": source["path"],
271
+ })
272
+ timings["edge_derivation_ms"] = round((time.perf_counter() - t0) * 1000, 3)
273
+
274
+ t0 = time.perf_counter()
275
+ nodes.sort(key=node_sort_key)
276
+ edges.sort(key=edge_sort_key)
277
+ payload = {"nodes": nodes, "edges": edges}
278
+ if args.profile:
279
+ payload["profile"] = rounded_timings(timings)
280
+ rendered = json.dumps(payload, indent=2, sort_keys=True)
281
+ timings["json_serialization_ms"] = round((time.perf_counter() - t0) * 1000, 3)
282
+ if args.profile:
283
+ timings["total_ms"] = round((time.perf_counter() - started) * 1000, 3)
284
+ payload["profile"] = rounded_timings(timings)
285
+ rendered = json.dumps(payload, indent=2, sort_keys=True)
286
+ sys.stdout.write(rendered)
287
+ sys.stdout.write("\n")
288
+ return 0
289
+
290
+
291
+ if __name__ == "__main__":
292
+ raise SystemExit(main())
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env python3
2
+ # THROWAWAY — spike prototype for E25_S01 latency measurement. Do not import.
3
+
4
+ import argparse
5
+ import re
6
+ import shutil
7
+ from pathlib import Path
8
+
9
+ BOARD_REF_RE = re.compile(r"\b(E\d{2}(?:_S\d{2})?(?:_T\d{2})?)\b")
10
+ FILE_GROUPS = [
11
+ ("board/epics", "*.md"),
12
+ ("board/stories", "*.md"),
13
+ ("board/tasks", "*.md"),
14
+ ("documentation/plans", "*.md"),
15
+ ("documentation/summaries", "*.md"),
16
+ ]
17
+
18
+
19
+ def parse_args() -> argparse.Namespace:
20
+ parser = argparse.ArgumentParser(description="Generate a deterministic synthetic 10x board for E25_S01.")
21
+ parser.add_argument("source_root", nargs="?", default="project", help="Source project-like root")
22
+ parser.add_argument("output_root", nargs="?", default="project/.synthetic/E25_S01_board_10x", help="Synthetic output root")
23
+ parser.add_argument("--replicas", type=int, default=10, help="Number of synthetic replicas to generate")
24
+ return parser.parse_args()
25
+
26
+
27
+ def prefix_for(index: int) -> str:
28
+ return f"SYNTH{index:02d}_"
29
+
30
+
31
+ def rewrite_board_refs(text: str, prefix: str) -> str:
32
+ return BOARD_REF_RE.sub(lambda match: f"{prefix}{match.group(1)}", text)
33
+
34
+
35
+ def rewrite_file(source_file: Path, destination_file: Path, prefix: str) -> None:
36
+ rewritten = rewrite_board_refs(source_file.read_text(encoding="utf-8"), prefix)
37
+ destination_file.write_text(rewritten, encoding="utf-8")
38
+
39
+
40
+ def ensure_clean_dir(path: Path) -> None:
41
+ if path.exists():
42
+ shutil.rmtree(path)
43
+ path.mkdir(parents=True, exist_ok=True)
44
+
45
+
46
+ def generate_docs(source_root: Path, output_root: Path, replicas: int) -> None:
47
+ for relative_dir, pattern in FILE_GROUPS:
48
+ source_dir = source_root / relative_dir
49
+ destination_dir = output_root / relative_dir
50
+ destination_dir.mkdir(parents=True, exist_ok=True)
51
+ for index in range(1, replicas + 1):
52
+ prefix = prefix_for(index)
53
+ for source_file in sorted(source_dir.glob(pattern)):
54
+ destination_name = f"{prefix}{source_file.name}"
55
+ rewrite_file(source_file, destination_dir / destination_name, prefix)
56
+
57
+
58
+ def generate_todo(source_root: Path, output_root: Path, replicas: int) -> None:
59
+ source_lines = (source_root / "todo.md").read_text(encoding="utf-8").splitlines()
60
+ preserved = []
61
+ entries = []
62
+ for line in source_lines:
63
+ stripped = line.strip()
64
+ if not stripped or stripped.startswith("#") or stripped.startswith("<!--") or set(stripped) <= {"-"}:
65
+ preserved.append(line)
66
+ else:
67
+ entries.append(line)
68
+ todo_lines = list(preserved)
69
+ if todo_lines and todo_lines[-1] != "":
70
+ todo_lines.append("")
71
+ for index in range(1, replicas + 1):
72
+ prefix = prefix_for(index)
73
+ todo_lines.append(f"<!-- {prefix[:-1]} -->")
74
+ todo_lines.extend(rewrite_board_refs(line, prefix) for line in entries)
75
+ todo_lines.append("")
76
+ (output_root / "todo.md").write_text("\n".join(todo_lines).rstrip() + "\n", encoding="utf-8")
77
+
78
+
79
+ def main() -> int:
80
+ args = parse_args()
81
+ source_root = Path(args.source_root).resolve()
82
+ output_root = Path(args.output_root).resolve()
83
+ ensure_clean_dir(output_root)
84
+ generate_docs(source_root, output_root, args.replicas)
85
+ generate_todo(source_root, output_root, args.replicas)
86
+ return 0
87
+
88
+
89
+ if __name__ == "__main__":
90
+ raise SystemExit(main())
@@ -0,0 +1,50 @@
1
+ {
2
+ "results": [
3
+ {
4
+ "command": "python3 scripts/e25_s01_extract_board_graph.py project/.synthetic/E25_S01_board_10x >/dev/null",
5
+ "mean": 0.9473466919,
6
+ "stddev": 0.04213956306422262,
7
+ "median": 0.9491800002000002,
8
+ "user": 0.27642483999999995,
9
+ "system": 0.17378274,
10
+ "min": 0.8851443542000001,
11
+ "max": 1.0109388122,
12
+ "times": [
13
+ 0.9744102712000001,
14
+ 0.8890629382000002,
15
+ 0.9989147292,
16
+ 0.8851443542000001,
17
+ 0.9441933962000001,
18
+ 0.9595782292000001,
19
+ 1.0109388122,
20
+ 0.9420493132000001,
21
+ 0.9150082712000001,
22
+ 0.9541666042000001
23
+ ],
24
+ "memory_usage_byte": [
25
+ 39600128,
26
+ 39600128,
27
+ 39600128,
28
+ 39600128,
29
+ 39600128,
30
+ 39600128,
31
+ 40730624,
32
+ 40730624,
33
+ 40730624,
34
+ 40730624
35
+ ],
36
+ "exit_codes": [
37
+ 0,
38
+ 0,
39
+ 0,
40
+ 0,
41
+ 0,
42
+ 0,
43
+ 0,
44
+ 0,
45
+ 0,
46
+ 0
47
+ ]
48
+ }
49
+ ]
50
+ }
@@ -0,0 +1,4 @@
1
+ Benchmark 1: python3 scripts/e25_s01_extract_board_graph.py project/.synthetic/E25_S01_board_10x >/dev/null
2
+ Time (mean ± σ): 947.3 ms ± 42.1 ms [User: 276.4 ms, System: 173.8 ms]
3
+ Range (min … max): 885.1 ms … 1010.9 ms 10 runs
4
+