@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,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
|
+
}
|