@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,615 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import json
8
+ import re
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ NODE_TYPES = {
14
+ "Epic",
15
+ "Story",
16
+ "Task",
17
+ "Plan",
18
+ "Summary",
19
+ "Doc",
20
+ "TodoEntry",
21
+ "Skill",
22
+ "Agent",
23
+ }
24
+ EDGE_TYPES = {
25
+ "contains",
26
+ "plans",
27
+ "summarizes",
28
+ "queued_as",
29
+ "mentions_skill",
30
+ "mentions_agent",
31
+ "depends_on",
32
+ "blocks",
33
+ "parent_doc",
34
+ }
35
+ FRONTMATTER_BOUNDARY = "---"
36
+ ROOT_DIR = Path(__file__).resolve().parents[3]
37
+ BOARD_ID_RE = re.compile(r"^E\d{2}(?:_S\d{2})?(?:_T\d{2})?$")
38
+ PLAN_RE = re.compile(r"^(E\d{2}_S\d{2}(?:_T\d{2})?)-plan$")
39
+ SUMMARY_RE = re.compile(r"^(E\d{2}_S\d{2}(?:_T\d{2})?)-summary$")
40
+ TODO_REF_AT_END_RE = re.compile(r"(E\d{2}_S\d{2}(?:_T\d{2})?)\s*$")
41
+
42
+
43
+ def build_parser() -> argparse.ArgumentParser:
44
+ parser = argparse.ArgumentParser(
45
+ prog="board-index",
46
+ description="Walk project artifacts and emit board-index graph JSON.",
47
+ )
48
+ subparsers = parser.add_subparsers(dest="command")
49
+
50
+ for command_name, help_text in (
51
+ ("graph::nodes", "Emit graph nodes as a JSON array."),
52
+ ("graph::edges", "Emit graph edges as a JSON array."),
53
+ ):
54
+ command_parser = subparsers.add_parser(command_name, help=help_text)
55
+ command_parser.add_argument("--type", dest="type_filter", help="Optional type filter.")
56
+ command_parser.add_argument(
57
+ "--format",
58
+ default="json",
59
+ choices=["json"],
60
+ help="Output format. Only JSON is supported in S02.",
61
+ )
62
+
63
+ return parser
64
+
65
+
66
+ def emit_json(payload: list[dict[str, Any]]) -> int:
67
+ json.dump(payload, sys.stdout, indent=2)
68
+ sys.stdout.write("\n")
69
+ return 0
70
+
71
+
72
+ def warn(message: str) -> None:
73
+ print(f"board-index: warning: {message}", file=sys.stderr)
74
+
75
+
76
+ def relative_path(path: Path) -> str:
77
+ return path.relative_to(ROOT_DIR).as_posix()
78
+
79
+
80
+ def parse_scalar(value: str) -> Any:
81
+ text = value.strip()
82
+ if not text or text in {"null", "~"}:
83
+ return None
84
+ if text in {"true", "false"}:
85
+ return text == "true"
86
+ if text.startswith("[") and text.endswith("]"):
87
+ inner = text[1:-1].strip()
88
+ if not inner:
89
+ return []
90
+ parts = [part.strip() for part in inner.split(",") if part.strip()]
91
+ return [strip_quotes(part) for part in parts]
92
+ return strip_quotes(text)
93
+
94
+
95
+ def strip_quotes(value: str) -> str:
96
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
97
+ return value[1:-1]
98
+ return value
99
+
100
+
101
+ def extract_frontmatter(path: Path) -> tuple[dict[str, Any], str]:
102
+ text = path.read_text(encoding="utf-8")
103
+ if not text.startswith(f"{FRONTMATTER_BOUNDARY}\n"):
104
+ return {}, text
105
+
106
+ lines = text.splitlines()
107
+ try:
108
+ end_index = lines[1:].index(FRONTMATTER_BOUNDARY) + 1
109
+ except ValueError as exc:
110
+ raise ValueError("frontmatter closing boundary not found") from exc
111
+
112
+ frontmatter_text = "\n".join(lines[1:end_index])
113
+ body = "\n".join(lines[end_index + 1 :])
114
+ return parse_yaml_like(frontmatter_text), body
115
+
116
+
117
+ def parse_yaml_like(text: str) -> dict[str, Any]:
118
+ lines = text.splitlines()
119
+ root: dict[str, Any] = {}
120
+ stack: list[tuple[int, dict[str, Any] | list[Any]]] = [(0, root)]
121
+ index = 0
122
+
123
+ while index < len(lines):
124
+ raw_line = lines[index]
125
+ if not raw_line.strip() or raw_line.lstrip().startswith("#"):
126
+ index += 1
127
+ continue
128
+
129
+ indent = len(raw_line) - len(raw_line.lstrip(" "))
130
+ stripped = raw_line.strip()
131
+
132
+ while len(stack) > 1 and indent < stack[-1][0]:
133
+ stack.pop()
134
+
135
+ container = stack[-1][1]
136
+
137
+ if stripped.startswith("- "):
138
+ if not isinstance(container, list):
139
+ raise ValueError(f"list item without list container: {raw_line}")
140
+ container.append(parse_scalar(stripped[2:]))
141
+ index += 1
142
+ continue
143
+
144
+ if not isinstance(container, dict) or ":" not in stripped:
145
+ raise ValueError(f"unsupported frontmatter line: {raw_line}")
146
+
147
+ key, remainder = stripped.split(":", 1)
148
+ key = key.strip()
149
+ remainder = remainder.strip()
150
+
151
+ if remainder in {">", "|"}:
152
+ block_lines: list[str] = []
153
+ block_index = index + 1
154
+ min_indent: int | None = None
155
+ while block_index < len(lines):
156
+ candidate = lines[block_index]
157
+ if not candidate.strip():
158
+ block_lines.append("")
159
+ block_index += 1
160
+ continue
161
+ candidate_indent = len(candidate) - len(candidate.lstrip(" "))
162
+ if candidate_indent <= indent:
163
+ break
164
+ if min_indent is None or candidate_indent < min_indent:
165
+ min_indent = candidate_indent
166
+ block_lines.append(candidate)
167
+ block_index += 1
168
+ normalized = []
169
+ for line in block_lines:
170
+ if not line:
171
+ normalized.append("")
172
+ continue
173
+ normalized.append(line[min_indent or 0 :].rstrip())
174
+ root_value = " ".join(part for part in normalized if part).strip() if remainder == ">" else "\n".join(normalized).strip()
175
+ container[key] = root_value or None
176
+ index = block_index
177
+ continue
178
+
179
+ if remainder == "":
180
+ next_index = index + 1
181
+ next_line = None
182
+ while next_index < len(lines):
183
+ candidate = lines[next_index]
184
+ if candidate.strip() and not candidate.lstrip().startswith("#"):
185
+ next_line = candidate
186
+ break
187
+ next_index += 1
188
+ if next_line is None:
189
+ container[key] = None
190
+ index += 1
191
+ continue
192
+ next_indent = len(next_line) - len(next_line.lstrip(" "))
193
+ if next_indent <= indent:
194
+ container[key] = None
195
+ index += 1
196
+ continue
197
+ if next_line.strip().startswith("- "):
198
+ new_list: list[Any] = []
199
+ container[key] = new_list
200
+ stack.append((next_indent, new_list))
201
+ else:
202
+ new_dict: dict[str, Any] = {}
203
+ container[key] = new_dict
204
+ stack.append((next_indent, new_dict))
205
+ index += 1
206
+ continue
207
+
208
+ container[key] = parse_scalar(remainder)
209
+ index += 1
210
+
211
+ return root
212
+
213
+
214
+ def normalize_list(value: Any) -> list[str]:
215
+ if value is None:
216
+ return []
217
+ if isinstance(value, list):
218
+ return [str(item).strip() for item in value if str(item).strip()]
219
+ text = str(value).strip()
220
+ return [text] if text else []
221
+
222
+
223
+ def first_heading(body: str) -> str | None:
224
+ for line in body.splitlines():
225
+ if line.startswith("#"):
226
+ return line.lstrip("#").strip()
227
+ return None
228
+
229
+
230
+ def first_paragraph(body: str) -> str:
231
+ paragraph_lines: list[str] = []
232
+ for line in body.splitlines():
233
+ stripped = line.strip()
234
+ if not stripped:
235
+ if paragraph_lines:
236
+ break
237
+ continue
238
+ if stripped.startswith("#"):
239
+ stripped = stripped.lstrip("#").strip()
240
+ paragraph_lines.append(stripped)
241
+ if len(paragraph_lines) >= 2:
242
+ break
243
+ return " ".join(paragraph_lines).strip()
244
+
245
+
246
+ def parse_markdown_with_frontmatter(path: Path, *, required_frontmatter: bool) -> tuple[dict[str, Any], str] | None:
247
+ try:
248
+ frontmatter, body = extract_frontmatter(path)
249
+ except Exception as exc: # noqa: BLE001
250
+ warn(f"skipping {relative_path(path)}: {exc}")
251
+ return None
252
+
253
+ if required_frontmatter and not frontmatter:
254
+ warn(f"skipping {relative_path(path)}: missing frontmatter")
255
+ return None
256
+
257
+ return frontmatter, body
258
+
259
+
260
+ def build_core_node(path: Path, node_type: str, frontmatter: dict[str, Any], body: str) -> dict[str, Any]:
261
+ title = (
262
+ frontmatter.get("title")
263
+ or first_heading(body)
264
+ or path.stem
265
+ )
266
+ return {
267
+ "id": frontmatter.get("id") or path.stem,
268
+ "type": node_type,
269
+ "title": title,
270
+ "status": frontmatter.get("status"),
271
+ "path": relative_path(path),
272
+ "mentions_skills": normalize_list(frontmatter.get("mentions_skills")),
273
+ "mentions_agents": normalize_list(frontmatter.get("mentions_agents")),
274
+ "depends_on": normalize_list(frontmatter.get("depends_on")),
275
+ "parent_doc": frontmatter.get("parent_doc"),
276
+ }
277
+
278
+
279
+ def extract_board_nodes_and_edges() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
280
+ nodes: list[dict[str, Any]] = []
281
+ edges: list[dict[str, Any]] = []
282
+ board_specs = (
283
+ (
284
+ "Epic",
285
+ ROOT_DIR / "project/board/epics",
286
+ {
287
+ "stories": "stories",
288
+ "epic_id": None,
289
+ "story_id": None,
290
+ },
291
+ ),
292
+ (
293
+ "Story",
294
+ ROOT_DIR / "project/board/stories",
295
+ {
296
+ "stories": None,
297
+ "tasks": "tasks",
298
+ "epic_id": "epic_id",
299
+ "story_id": None,
300
+ },
301
+ ),
302
+ (
303
+ "Task",
304
+ ROOT_DIR / "project/board/tasks",
305
+ {
306
+ "stories": None,
307
+ "tasks": None,
308
+ "epic_id": "epic_id",
309
+ "story_id": "story_id",
310
+ },
311
+ ),
312
+ )
313
+
314
+ for node_type, directory, fields in board_specs:
315
+ if not directory.exists():
316
+ continue
317
+ for path in sorted(directory.glob("*.md")):
318
+ parsed = parse_markdown_with_frontmatter(path, required_frontmatter=True)
319
+ if parsed is None:
320
+ continue
321
+ frontmatter, body = parsed
322
+ node = build_core_node(path, node_type, frontmatter, body)
323
+ node["date_created"] = frontmatter.get("date_created")
324
+ node["date_started"] = frontmatter.get("date_started")
325
+ node["date_completed"] = frontmatter.get("date_completed")
326
+ if fields.get("epic_id"):
327
+ node["epic_id"] = frontmatter.get("epic_id") or frontmatter.get("epic")
328
+ if fields.get("story_id"):
329
+ node["story_id"] = frontmatter.get("story_id")
330
+ if fields.get("stories"):
331
+ node["stories"] = normalize_list(frontmatter.get("stories"))
332
+ for child_id in node["stories"]:
333
+ edges.append({"from": node["id"], "to": child_id, "type": "contains"})
334
+ if fields.get("tasks"):
335
+ node["tasks"] = normalize_list(frontmatter.get("tasks"))
336
+ for child_id in node["tasks"]:
337
+ edges.append({"from": node["id"], "to": child_id, "type": "contains"})
338
+ nodes.append(node)
339
+
340
+ return nodes, edges
341
+
342
+
343
+ def build_graph() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
344
+ nodes, edges = extract_board_nodes_and_edges()
345
+ extra_nodes, extra_edges = extract_documentation_nodes_and_edges()
346
+ nodes.extend(extra_nodes)
347
+ edges.extend(extra_edges)
348
+ skill_nodes = extract_skill_nodes()
349
+ agent_nodes = extract_agent_nodes()
350
+ nodes.extend(skill_nodes)
351
+ nodes.extend(agent_nodes)
352
+ edges.extend(derive_optional_edges(nodes))
353
+ return sort_nodes(nodes), sort_edges(edges)
354
+
355
+
356
+ def infer_story_id(ref_id: str | None) -> str | None:
357
+ if not ref_id:
358
+ return None
359
+ if "_T" in ref_id:
360
+ return ref_id.rsplit("_T", 1)[0]
361
+ return ref_id if BOARD_ID_RE.match(ref_id) else None
362
+
363
+
364
+ def infer_plan_or_summary_targets(stem: str, pattern: re.Pattern[str]) -> tuple[str | None, str | None]:
365
+ match = pattern.match(stem)
366
+ if not match:
367
+ return None, None
368
+ target_id = match.group(1)
369
+ return infer_story_id(target_id), target_id if "_T" in target_id else None
370
+
371
+
372
+ def extract_documentation_nodes_and_edges() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
373
+ nodes: list[dict[str, Any]] = []
374
+ edges: list[dict[str, Any]] = []
375
+ documentation_dir = ROOT_DIR / "project/documentation"
376
+
377
+ plan_dir = documentation_dir / "plans"
378
+ if plan_dir.exists():
379
+ for path in sorted(plan_dir.glob("*.md")):
380
+ parsed = parse_markdown_with_frontmatter(path, required_frontmatter=False)
381
+ if parsed is None:
382
+ continue
383
+ frontmatter, body = parsed
384
+ story_id, task_id = infer_plan_or_summary_targets(path.stem, PLAN_RE)
385
+ node = build_core_node(path, "Plan", frontmatter, body)
386
+ node["story_id"] = story_id
387
+ node["task_id"] = task_id
388
+ nodes.append(node)
389
+ target_id = task_id or story_id
390
+ if target_id:
391
+ edges.append({"from": node["id"], "to": target_id, "type": "plans"})
392
+
393
+ summary_dir = documentation_dir / "summaries"
394
+ if summary_dir.exists():
395
+ for path in sorted(summary_dir.glob("*.md")):
396
+ parsed = parse_markdown_with_frontmatter(path, required_frontmatter=False)
397
+ if parsed is None:
398
+ continue
399
+ frontmatter, body = parsed
400
+ story_id, task_id = infer_plan_or_summary_targets(path.stem, SUMMARY_RE)
401
+ node = build_core_node(path, "Summary", frontmatter, body)
402
+ node["story_id"] = story_id
403
+ node["task_id"] = task_id
404
+ nodes.append(node)
405
+ target_id = task_id or story_id
406
+ if target_id:
407
+ edges.append({"from": node["id"], "to": target_id, "type": "summarizes"})
408
+
409
+ example_dir = documentation_dir / "examples"
410
+ seen_docs: set[Path] = set()
411
+ if example_dir.exists():
412
+ for path in sorted(example_dir.glob("*.md")):
413
+ parsed = parse_markdown_with_frontmatter(path, required_frontmatter=False)
414
+ if parsed is None:
415
+ continue
416
+ frontmatter, body = parsed
417
+ node = build_core_node(path, "Doc", frontmatter, body)
418
+ nodes.append(node)
419
+ seen_docs.add(path.resolve())
420
+
421
+ for path in sorted(documentation_dir.glob("*.md")):
422
+ if path.resolve() in seen_docs:
423
+ continue
424
+ parsed = parse_markdown_with_frontmatter(path, required_frontmatter=False)
425
+ if parsed is None:
426
+ continue
427
+ frontmatter, body = parsed
428
+ node = build_core_node(path, "Doc", frontmatter, body)
429
+ nodes.append(node)
430
+
431
+ todo_path = ROOT_DIR / "project/todo.md"
432
+ if todo_path.exists():
433
+ todo_nodes, todo_edges = extract_todo_nodes_and_edges(todo_path)
434
+ nodes.extend(todo_nodes)
435
+ edges.extend(todo_edges)
436
+
437
+ return nodes, edges
438
+
439
+
440
+ def extract_todo_nodes_and_edges(path: Path) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
441
+ nodes: list[dict[str, Any]] = []
442
+ edges: list[dict[str, Any]] = []
443
+ in_comment_block = False
444
+
445
+ for raw_line in path.read_text(encoding="utf-8").splitlines():
446
+ stripped = raw_line.strip()
447
+ if in_comment_block:
448
+ if "-->" in raw_line:
449
+ in_comment_block = False
450
+ continue
451
+ if not stripped:
452
+ continue
453
+ if stripped.startswith("<!--"):
454
+ if "-->" not in stripped:
455
+ in_comment_block = True
456
+ continue
457
+
458
+ board_ref_match = TODO_REF_AT_END_RE.search(stripped)
459
+ board_ref = board_ref_match.group(1) if board_ref_match else None
460
+ normalized = " ".join(stripped.split())
461
+ fallback_id = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
462
+ node_id = board_ref or fallback_id
463
+ nodes.append(
464
+ {
465
+ "id": node_id,
466
+ "type": "TodoEntry",
467
+ "title": normalized,
468
+ "status": None,
469
+ "path": relative_path(path),
470
+ "text": stripped,
471
+ "board_ref": board_ref,
472
+ }
473
+ )
474
+ if board_ref:
475
+ edges.append({"from": node_id, "to": board_ref, "type": "queued_as"})
476
+
477
+ return nodes, edges
478
+
479
+
480
+ def extract_description_from_markdown(path: Path) -> tuple[dict[str, Any], str, str]:
481
+ parsed = parse_markdown_with_frontmatter(path, required_frontmatter=False)
482
+ if parsed is None:
483
+ return {}, "", ""
484
+ frontmatter, body = parsed
485
+ description = str(frontmatter.get("description") or first_paragraph(body) or "").strip()
486
+ return frontmatter, body, description
487
+
488
+
489
+ def deduplicate_named_nodes(candidates: list[dict[str, Any]]) -> list[dict[str, Any]]:
490
+ preferred: dict[str, dict[str, Any]] = {}
491
+ for node in candidates:
492
+ current = preferred.get(node["id"])
493
+ if current is None:
494
+ preferred[node["id"]] = node
495
+ continue
496
+ current_is_root = not current["path"].startswith(".agents/")
497
+ new_is_root = not node["path"].startswith(".agents/")
498
+ if new_is_root and not current_is_root:
499
+ preferred[node["id"]] = node
500
+ return sort_nodes(list(preferred.values()))
501
+
502
+
503
+ def extract_skill_nodes() -> list[dict[str, Any]]:
504
+ candidates: list[dict[str, Any]] = []
505
+ for base_dir in (ROOT_DIR / "skills", ROOT_DIR / ".agents/skills"):
506
+ if not base_dir.exists():
507
+ continue
508
+ for skill_file in sorted(base_dir.glob("*/SKILL.md")):
509
+ frontmatter, body, description = extract_description_from_markdown(skill_file)
510
+ skill_id = skill_file.parent.name
511
+ candidates.append(
512
+ {
513
+ "id": skill_id,
514
+ "type": "Skill",
515
+ "title": frontmatter.get("name") or skill_id,
516
+ "status": None,
517
+ "path": relative_path(skill_file),
518
+ "description": description,
519
+ }
520
+ )
521
+ return deduplicate_named_nodes(candidates)
522
+
523
+
524
+ def extract_agent_nodes() -> list[dict[str, Any]]:
525
+ candidates: list[dict[str, Any]] = []
526
+ base_patterns = (
527
+ ROOT_DIR / "agents",
528
+ ROOT_DIR / ".agents",
529
+ ROOT_DIR / ".agents/agents",
530
+ )
531
+ for base_dir in base_patterns:
532
+ if not base_dir.exists():
533
+ continue
534
+ for agent_file in sorted(base_dir.glob("*.md")):
535
+ frontmatter, body, description = extract_description_from_markdown(agent_file)
536
+ agent_id = agent_file.stem
537
+ candidates.append(
538
+ {
539
+ "id": agent_id,
540
+ "type": "Agent",
541
+ "title": frontmatter.get("name") or first_heading(body) or agent_id,
542
+ "status": None,
543
+ "path": relative_path(agent_file),
544
+ "description": description,
545
+ }
546
+ )
547
+ return deduplicate_named_nodes(candidates)
548
+
549
+
550
+ def derive_optional_edges(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
551
+ edges: list[dict[str, Any]] = []
552
+ skill_ids = {node["id"] for node in nodes if node.get("type") == "Skill"}
553
+ agent_ids = {node["id"] for node in nodes if node.get("type") == "Agent"}
554
+
555
+ for node in nodes:
556
+ node_type = node.get("type")
557
+ for skill_id in normalize_list(node.get("mentions_skills")):
558
+ edges.append({"from": node["id"], "to": skill_id, "type": "mentions_skill"})
559
+ if skill_id not in skill_ids:
560
+ node.setdefault("debug", {}).setdefault("unknown_mentions_skills", []).append(skill_id)
561
+
562
+ for agent_id in normalize_list(node.get("mentions_agents")):
563
+ edges.append({"from": node["id"], "to": agent_id, "type": "mentions_agent"})
564
+ if agent_id not in agent_ids:
565
+ node.setdefault("debug", {}).setdefault("unknown_mentions_agents", []).append(agent_id)
566
+
567
+ if node_type in {"Story", "Task"}:
568
+ for dependency_id in normalize_list(node.get("depends_on")):
569
+ edges.append({"from": node["id"], "to": dependency_id, "type": "depends_on"})
570
+ edges.append({"from": dependency_id, "to": node["id"], "type": "blocks"})
571
+
572
+ if node_type == "Doc" and node.get("parent_doc"):
573
+ edges.append({"from": node["id"], "to": str(node["parent_doc"]), "type": "parent_doc"})
574
+
575
+ return edges
576
+
577
+
578
+ def sort_nodes(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
579
+ return sorted(nodes, key=lambda node: (node.get("type", ""), node.get("id", ""), node.get("path", "")))
580
+
581
+
582
+ def sort_edges(edges: list[dict[str, Any]]) -> list[dict[str, Any]]:
583
+ return sorted(edges, key=lambda edge: (edge.get("type", ""), edge.get("from", ""), edge.get("to", "")))
584
+
585
+
586
+ def filter_by_type(items: list[dict[str, Any]], type_filter: str | None, allowed: set[str]) -> list[dict[str, Any]]:
587
+ if not type_filter:
588
+ return items
589
+ if type_filter not in allowed:
590
+ return []
591
+ return [item for item in items if item.get("type") == type_filter]
592
+
593
+
594
+ def main(argv: list[str] | None = None) -> int:
595
+ parser = build_parser()
596
+ args = parser.parse_args(argv)
597
+
598
+ if not args.command:
599
+ parser.print_help(sys.stderr)
600
+ return 1
601
+
602
+ nodes, edges = build_graph()
603
+
604
+ if args.command == "graph::nodes":
605
+ return emit_json(filter_by_type(nodes, args.type_filter, NODE_TYPES))
606
+
607
+ if args.command == "graph::edges":
608
+ return emit_json(filter_by_type(edges, args.type_filter, EDGE_TYPES))
609
+
610
+ parser.print_help(sys.stderr)
611
+ return 1
612
+
613
+
614
+ if __name__ == "__main__":
615
+ raise SystemExit(main())
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
5
+ REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
6
+ BOARD_INDEX="$SCRIPT_DIR/board-index"
7
+
8
+ "$BOARD_INDEX" graph::nodes | python3 -m json.tool >/dev/null
9
+ "$BOARD_INDEX" graph::edges | python3 -m json.tool >/dev/null
10
+
11
+ export REPO_ROOT BOARD_INDEX
12
+ python3 - <<'PY'
13
+ import json
14
+ import os
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ repo_root = Path(os.environ["REPO_ROOT"])
20
+ board_index = os.environ["BOARD_INDEX"]
21
+
22
+ nodes = json.loads(subprocess.check_output([board_index, "graph::nodes"], text=True))
23
+ edges = json.loads(subprocess.check_output([board_index, "graph::edges"], text=True))
24
+
25
+ node_counts = {}
26
+ for node in nodes:
27
+ node_counts[node["type"]] = node_counts.get(node["type"], 0) + 1
28
+
29
+ thresholds = {
30
+ "Epic": 22,
31
+ "Story": 90,
32
+ "Task": 150,
33
+ "TodoEntry": 10,
34
+ "Skill": 5,
35
+ "Agent": 2,
36
+ }
37
+
38
+ for node_type, minimum in thresholds.items():
39
+ actual = node_counts.get(node_type, 0)
40
+ if actual < minimum:
41
+ raise SystemExit(f"{node_type} count too low: expected >= {minimum}, got {actual}")
42
+
43
+ contains_edges = [edge for edge in edges if edge["type"] == "contains"]
44
+ queued_edges = [edge for edge in edges if edge["type"] == "queued_as"]
45
+ if not contains_edges:
46
+ raise SystemExit("contains edge set is empty")
47
+ if not queued_edges:
48
+ raise SystemExit("queued_as edge set is empty")
49
+
50
+ allowed_prefixes = ("project/", "skills/", "agents/", ".agents/")
51
+ bad_paths = sorted(
52
+ {
53
+ node["path"]
54
+ for node in nodes
55
+ if "path" in node and node["path"] and not node["path"].startswith(allowed_prefixes)
56
+ }
57
+ )
58
+ if bad_paths:
59
+ raise SystemExit(f"unexpected node paths found: {bad_paths}")
60
+
61
+ throwaway_files = [
62
+ repo_root / "scripts/e25_s01_extract_board_graph.py",
63
+ repo_root / "scripts/e25_s01_generate_synthetic_board.py",
64
+ ]
65
+ for throwaway_file in throwaway_files:
66
+ if throwaway_file.exists():
67
+ content = throwaway_file.read_text(encoding="utf-8")
68
+ if "# THROWAWAY" not in content:
69
+ raise SystemExit(f"throwaway marker missing: {throwaway_file.relative_to(repo_root)}")
70
+
71
+ new_library_files = [
72
+ repo_root / "skills/index/scripts/board_index.py",
73
+ repo_root / "skills/index/scripts/board-index",
74
+ ]
75
+ blocked_names = {
76
+ "e25_s01_extract_board_graph.py",
77
+ "e25_s01_generate_synthetic_board.py",
78
+ }
79
+ for library_file in new_library_files:
80
+ content = library_file.read_text(encoding="utf-8")
81
+ for blocked_name in blocked_names:
82
+ if blocked_name in content:
83
+ raise SystemExit(f"throwaway reference leaked into {library_file.relative_to(repo_root)}")
84
+
85
+ print("SMOKE TEST PASSED")
86
+ PY