@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.
- package/LICENSE +201 -0
- package/README.md +340 -0
- package/agents/ai_engineer.md +113 -0
- package/agents/developer.md +236 -0
- package/agents/scrum-master.md +349 -0
- package/agents/scrutiny-agent.md +137 -0
- package/agents/solution-assessor.md +185 -0
- package/agents/tester.md +339 -0
- package/bin/jenga.js +70 -0
- package/hooks/copilot_session_end.sh +29 -0
- package/hooks/on_session_end.sh +238 -0
- package/hooks/prompt_router.sh +11 -0
- package/hooks/prompt_router_helper.js +52 -0
- package/hooks/session_end_helper.js +29 -0
- package/hooks/session_end_watcher.sh +24 -0
- package/lib/commands/attach.js +47 -0
- package/lib/commands/init.js +207 -0
- package/lib/commands/start.js +16 -0
- package/lib/commands/status.js +53 -0
- package/lib/config-schema.js +72 -0
- package/lib/inject-settings.js +61 -0
- package/lib/mirror.js +244 -0
- package/lib/resolve-project-dir.sh +47 -0
- package/mcp/execute-ticket/index.js +10 -0
- package/mcp/execute-ticket/package.json +5 -0
- package/mcp/help/index.js +79 -0
- package/mcp/help/package.json +14 -0
- package/mcp/router/README.md +19 -0
- package/mcp/router/embedder.js +23 -0
- package/mcp/router/index.js +204 -0
- package/mcp/router/matcher.js +87 -0
- package/mcp/router/package-lock.json +1048 -0
- package/mcp/router/package.json +11 -0
- package/mcp/router/skill-index.js +104 -0
- package/package.json +47 -0
- package/scripts/board_resolver.sh +46 -0
- package/scripts/e25_s01_extract_board_graph.py +292 -0
- package/scripts/e25_s01_generate_synthetic_board.py +90 -0
- package/scripts/measurement-10x.json +50 -0
- package/scripts/measurement-10x.txt +4 -0
- package/scripts/measurement-real.json +50 -0
- package/scripts/measurement-real.txt +4 -0
- package/scripts/postinstall.js +165 -0
- package/scripts/todo_cleanup.sh +22 -0
- package/scripts/todo_manager.sh +86 -0
- package/scripts/validate-board.sh +190 -0
- package/scripts/validate-story-format.sh +53 -0
- package/skills/brainstorm/SKILL.md +47 -0
- package/skills/btw/SKILL.md +42 -0
- package/skills/commit/SKILL.md +29 -0
- package/skills/commit/assets/user_instructions_template.md +22 -0
- package/skills/continue/SKILL.md +29 -0
- package/skills/convert/SKILL.md +124 -0
- package/skills/convert/convert_cli.py +235 -0
- package/skills/convert/tests/sample.csv +4 -0
- package/skills/convert/tests/sample.json +5 -0
- package/skills/convert/tests/sample.jsonl +3 -0
- package/skills/convert/tests/sample.yaml +18 -0
- package/skills/convert/tests/sample_obj.csv +2 -0
- package/skills/convert/tests/sample_obj.json +9 -0
- package/skills/deep-dive/SKILL.md +167 -0
- package/skills/do/SKILL.md +88 -0
- package/skills/do/assets/sender_template.json +12 -0
- package/skills/doc/SKILL.md +314 -0
- package/skills/doc/assets/path-objectives.yaml +38 -0
- package/skills/doc-sync/SKILL.md +167 -0
- package/skills/doc-sync/assets/default_excludes.txt +21 -0
- package/skills/doc-sync/assets/doc_targets.md +14 -0
- package/skills/dooo/SKILL.md +60 -0
- package/skills/error/SKILL.md +29 -0
- package/skills/evaluate/SKILL.md +45 -0
- package/skills/evaluate/assets/evaluation_invokation_template.yml +3 -0
- package/skills/evaluate/assets/evaluation_rapport_template.md +24 -0
- package/skills/examplify/SKILL.md +42 -0
- package/skills/help/SKILL.md +36 -0
- package/skills/improve/SKILL.md +55 -0
- package/skills/index/scripts/board-index +4 -0
- package/skills/index/scripts/board_index.py +615 -0
- package/skills/index/scripts/smoke_test.sh +86 -0
- package/skills/init/SKILL.md +44 -0
- package/skills/init/assets/.gitignore_template +15 -0
- package/skills/init/assets/PROJECT_SUMMARY_template.md +13 -0
- package/skills/init/assets/directory_structure.txt +13 -0
- package/skills/init/assets/test-config_template.json +4 -0
- package/skills/init/assets/workflow_template.json +30 -0
- package/skills/init/scripts/init.sh +48 -0
- package/skills/jbp/SKILL.md +25 -0
- package/skills/jenga/SKILL.md +68 -0
- package/skills/lgtm/SKILL.md +21 -0
- package/skills/mirror-public/SKILL.md +237 -0
- package/skills/mirror-public/assets/config.json +5 -0
- package/skills/mirror-public/scripts/mirror.sh +374 -0
- package/skills/pi-plan/SKILL.md +62 -0
- package/skills/pi-plan/assets/epic.json +7 -0
- package/skills/pi-plan/assets/story_template.md +18 -0
- package/skills/proceed/SKILL.md +29 -0
- package/skills/publish/SKILL.md +351 -0
- package/skills/publish/adapters/droplet.md +200 -0
- package/skills/publish/adapters/mobile-ios.md +114 -0
- package/skills/publish/adapters/npm-ci.md +223 -0
- package/skills/publish/adapters/npm.md +121 -0
- package/skills/publish/assets/ExportOptions.plist.template +19 -0
- package/skills/publish/assets/ci-contract.md +111 -0
- package/skills/publish/assets/ownership-matrix.md +17 -0
- package/skills/publish/assets/publish.example.json +85 -0
- package/skills/publish/assets/publish.example.npm-ci.json +40 -0
- package/skills/publish/assets/publish.example.npm.json +41 -0
- package/skills/publish/assets/secrets-guide.md +104 -0
- package/skills/publish/schemas/fixtures/npm-ci-minimal.json +17 -0
- package/skills/publish/schemas/fixtures/npm-ci-with-empty-secrets.json +18 -0
- package/skills/publish/schemas/fixtures/npm-ci-with-workflow-path.json +18 -0
- package/skills/publish/schemas/publish.schema.json +428 -0
- package/skills/publish/scripts/check_target_config.sh +96 -0
- package/skills/publish/scripts/droplet_pipeline.sh +208 -0
- package/skills/publish/scripts/generate_release_notes.sh +200 -0
- package/skills/publish/scripts/ios_pipeline.sh +486 -0
- package/skills/publish/scripts/npm_ci_pipeline.sh +225 -0
- package/skills/publish/scripts/npm_pipeline.sh +249 -0
- package/skills/publish/scripts/publish_common.sh +253 -0
- package/skills/publish/scripts/publish_deploy.sh +538 -0
- package/skills/publish/scripts/reconcile_tags.sh +135 -0
- package/skills/publish/scripts/run_gates.sh +616 -0
- package/skills/publish/scripts/setup_wizard.sh +394 -0
- package/skills/publish/scripts/show_history.sh +95 -0
- package/skills/publish/scripts/suggest_semver_bump.sh +105 -0
- package/skills/publish/scripts/validate_config.sh +163 -0
- package/skills/publish/scripts/validate_droplet_env.sh +45 -0
- package/skills/publish/scripts/validate_ios_env.sh +68 -0
- package/skills/publish/scripts/validate_npm_ci_env.sh +71 -0
- package/skills/publish/scripts/validate_npm_env.sh +22 -0
- package/skills/publish/scripts/write_ledger_entry.sh +126 -0
- package/skills/publish/wizards/droplet.md +275 -0
- package/skills/publish/wizards/mobile-ios.md +157 -0
- package/skills/publish/wizards/npm-ci.md +240 -0
- package/skills/publish/wizards/npm.md +224 -0
- package/skills/reconcile/SKILL.md +93 -0
- package/skills/reconcile/assets/report_format.md +44 -0
- package/skills/reconcile-origin/SKILL.md +75 -0
- package/skills/reconcile-origin/scripts/reconcile-origin.sh +372 -0
- package/skills/redo/SKILL.md +70 -0
- package/skills/route/SKILL.md +180 -0
- package/skills/self-sync/SKILL.md +73 -0
- package/skills/self-sync/scripts/run.js +136 -0
- package/skills/skillify/SKILL.md +68 -0
- package/skills/skillify/assets/init-new/SKILL.md +35 -0
- package/skills/skillify/assets/init-new/assets/.gitignore_template +15 -0
- package/skills/skillify/assets/init-new/assets/PROJECT_SUMMARY_template.md +13 -0
- package/skills/skillify/assets/init-new/assets/directory_structure.txt +10 -0
- package/skills/skillify/assets/init-new/assets/test-config_template.json +4 -0
- package/skills/skillify/assets/init-new/assets/workflow_template.json +17 -0
- package/skills/skillify/assets/init-new/scripts/init.sh +48 -0
- package/skills/skillify/assets/init-old/SKILL.md +124 -0
- package/skills/spinoff/SKILL.md +48 -0
- package/skills/status/SKILL.md +33 -0
- package/skills/status/assets/output_format.md +41 -0
- package/skills/todo/SKILL.md +46 -0
- package/skills/todo/assets/todo_handoff_template.md +22 -0
- package/skills/todo/assets/todo_template.md +3 -0
- package/skills/train/SKILL.md +116 -0
- package/skills/train/assets/dashboard-templates/classifiers.html +106 -0
- package/skills/train/assets/dashboard-templates/nlp.html +102 -0
- package/skills/train/assets/dashboard-templates/transformers.html +98 -0
- package/skills/train/assets/results-parsers/__init__.py +9 -0
- package/skills/train/assets/results-parsers/classifiers.py +84 -0
- package/skills/train/assets/results-parsers/nlp.py +88 -0
- package/skills/train/assets/results-parsers/reporter.py +154 -0
- package/skills/train/assets/results-parsers/transformers.py +120 -0
- package/skills/train/train_cli.py +786 -0
- package/templates/EXECUTION_PLAN_TEMPLATE.md +43 -0
- package/templates/EXECUTION_SUMMARY_TEMPLATE.md +50 -0
- package/templates/JENGA_CONFIG_TEMPLATE.json +23 -0
- package/templates/PROBLEM_RAPPORT_TEMPLATE.md +88 -0
- package/templates/SCRUM_BOARD_SCHEMA.md +311 -0
- package/templates/SKILL.md +16 -0
- package/templates/SKILL_TEMPLATE.md +28 -0
- package/templates/USER_INSTRUCTIONS_TEMPLATE.md +22 -0
- 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
|