@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,207 @@
1
+ import { createInterface } from "readline";
2
+ import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync, readdirSync } from "fs";
3
+ import { join, dirname } from "path";
4
+ import { fileURLToPath } from "url";
5
+ import { validateConfig } from "../config-schema.js";
6
+ import { injectSettings } from "../inject-settings.js";
7
+
8
+ const CONFIG_FILE = "jenga.cli.json";
9
+
10
+ // This file lives at <package>/lib/commands/init.js — two levels up is the
11
+ // installed jenga-agent package root, which holds templates/ and other
12
+ // framework assets sourced directly from node_modules.
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const PACKAGE_ROOT = join(__dirname, "..", "..");
15
+
16
+ async function prompt(rl, question, defaultValue) {
17
+ return new Promise(resolve => {
18
+ const hint = defaultValue !== undefined ? ` (default: ${defaultValue})` : "";
19
+ rl.question(`${question}${hint}: `, answer => {
20
+ resolve(answer.trim() || String(defaultValue ?? ""));
21
+ });
22
+ });
23
+ }
24
+
25
+ export async function runInit(args, projectRoot = process.cwd()) {
26
+ const configPath = join(projectRoot, CONFIG_FILE);
27
+
28
+ // Load existing config as defaults if re-running
29
+ let existing = {};
30
+ if (existsSync(configPath)) {
31
+ try {
32
+ existing = JSON.parse(readFileSync(configPath, "utf8"));
33
+ console.log(`Found existing ${CONFIG_FILE} — showing current values as defaults.\n`);
34
+ } catch {
35
+ console.warn(`Warning: Could not parse existing ${CONFIG_FILE}. Starting fresh.\n`);
36
+ }
37
+ } else {
38
+ console.log("Welcome to Jenga! Let's set up your configuration.\n");
39
+ }
40
+
41
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
42
+
43
+ try {
44
+ // 1. Agent target (supports comma-separated for multiple targets)
45
+ const validTargets = ["claude", "copilot", "custom"];
46
+ const existingTargets = existing.agentTarget
47
+ ? (Array.isArray(existing.agentTarget) ? existing.agentTarget.join(", ") : existing.agentTarget)
48
+ : "claude";
49
+ console.log("Agent target options: claude, copilot, custom (comma-separate for multiple, e.g. claude, copilot)");
50
+ let agentTargetRaw;
51
+ let parsedTargets;
52
+ do {
53
+ agentTargetRaw = await prompt(rl, "Agent target", existingTargets);
54
+ parsedTargets = agentTargetRaw.split(",").map(s => s.trim()).filter(Boolean);
55
+ if (!parsedTargets.every(t => validTargets.includes(t))) {
56
+ console.log(' Please enter valid targets: claude, copilot, custom');
57
+ parsedTargets = null;
58
+ }
59
+ } while (!parsedTargets);
60
+ const agentTarget = parsedTargets.length === 1 ? parsedTargets[0] : parsedTargets;
61
+
62
+ // 2. Skills path — postinstall mirrors skills/ into .claude/skills/ (Claude)
63
+ // and .agents/skills/ (Copilot / custom). Default reflects the chosen target(s):
64
+ // a single string for one target, an array for multi-target installs.
65
+ const skillPathForTarget = (t) => t === "claude" ? ".claude/skills/" : ".agents/skills/";
66
+ const derivedDefault = (() => {
67
+ const unique = [...new Set(parsedTargets.map(skillPathForTarget))];
68
+ return unique.length === 1 ? unique[0] : unique;
69
+ })();
70
+ const existingSkillsPath = existing.skillsPath ?? derivedDefault;
71
+ const skillsDefaultHint = Array.isArray(existingSkillsPath)
72
+ ? existingSkillsPath.join(", ")
73
+ : existingSkillsPath;
74
+ const skillsRaw = await prompt(rl, "Skills directory path(s) (comma-separate for multiple)", skillsDefaultHint);
75
+ const skillsParsed = skillsRaw.split(",").map(s => s.trim()).filter(Boolean);
76
+ const skillsPath = skillsParsed.length === 1 ? skillsParsed[0] : skillsParsed;
77
+
78
+ // 3. Match threshold
79
+ const thresholdDefault = existing.matchThreshold ?? 0.75;
80
+ let matchThreshold;
81
+ do {
82
+ const raw = await prompt(rl, "Match confidence threshold (0–1)", thresholdDefault);
83
+ matchThreshold = parseFloat(raw);
84
+ } while (isNaN(matchThreshold) || matchThreshold < 0 || matchThreshold > 1);
85
+
86
+ // 4. Session timeout (minutes in wizard → ms in config)
87
+ const timeoutMinutesDefault = existing.sessionTimeout ? existing.sessionTimeout / 60000 : 5;
88
+ let sessionTimeoutMinutes;
89
+ do {
90
+ const raw = await prompt(rl, "Session timeout (minutes)", timeoutMinutesDefault);
91
+ sessionTimeoutMinutes = parseFloat(raw);
92
+ } while (isNaN(sessionTimeoutMinutes) || sessionTimeoutMinutes <= 0);
93
+
94
+ const config = {
95
+ agentTarget,
96
+ skillsPath,
97
+ matchThreshold,
98
+ sessionTimeout: Math.round(sessionTimeoutMinutes * 60000),
99
+ };
100
+
101
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
102
+
103
+ // Assert the written config passes schema validation
104
+ const validation = validateConfig(config);
105
+ if (!validation.valid) {
106
+ throw new Error(`Config validation failed: ${validation.error}`);
107
+ }
108
+
109
+ console.log(`\n✓ Configuration written to ${CONFIG_FILE}`);
110
+
111
+ // Ensure .jenga/ is in .gitignore (idempotent)
112
+ const gitignorePath = join(projectRoot, ".gitignore");
113
+ const gitignoreEntry = ".jenga/";
114
+ let gitignoreContent = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf8") : "";
115
+ if (!gitignoreContent.split("\n").some(line => line.trim() === gitignoreEntry)) {
116
+ const prefix = gitignoreContent.length > 0 && !gitignoreContent.endsWith("\n") ? "\n" : "";
117
+ appendFileSync(gitignorePath, `${prefix}${gitignoreEntry}\n`, "utf8");
118
+ }
119
+
120
+ try {
121
+ await injectSettings(projectRoot);
122
+ } catch (e) {
123
+ console.warn(`Warning: Could not register hook in settings.json — ${e.message}`);
124
+ console.warn("You can register it manually later by running jenga init again.");
125
+ }
126
+
127
+ // Generate .github/copilot-instructions.md from template.
128
+ // Templates ship inside the jenga-agent package (node_modules/jenga-agent/templates/).
129
+ // Fall back to a bare `templates/` at the project root only for the dev-repo case.
130
+ try {
131
+ const tplCandidates = [
132
+ join(PACKAGE_ROOT, "templates", "copilot-instructions.md.tpl"),
133
+ join(projectRoot, "templates", "copilot-instructions.md.tpl"),
134
+ ];
135
+ const tplPath = tplCandidates.find(existsSync);
136
+ if (tplPath) {
137
+ const tpl = readFileSync(tplPath, "utf8");
138
+
139
+ // Build skill list from skillsPath. skillsPath may be a string or an
140
+ // array (multi-target installs); pick the first existing directory —
141
+ // mirrored copies hold identical content.
142
+ const skillsPathList = Array.isArray(config.skillsPath) ? config.skillsPath : [config.skillsPath];
143
+ const skillsDir = skillsPathList
144
+ .map(p => join(projectRoot, p))
145
+ .find(existsSync);
146
+ let skillLines = [];
147
+ if (skillsDir) {
148
+ for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
149
+ if (!entry.isDirectory()) continue;
150
+ const skillMdPath = join(skillsDir, entry.name, "SKILL.md");
151
+ let description = "";
152
+ if (existsSync(skillMdPath)) {
153
+ const content = readFileSync(skillMdPath, "utf8");
154
+ const match = content.match(/^description:\s*(.+)$/m);
155
+ if (match) description = match[1].trim();
156
+ }
157
+ skillLines.push(`- **${entry.name}**${description ? `: ${description}` : ""}`);
158
+ }
159
+ }
160
+ const skillList = skillLines.length > 0 ? skillLines.join("\n") : "_No skills found._";
161
+ const rendered = tpl.replace("{{SKILL_LIST}}", skillList);
162
+
163
+ const githubDir = join(projectRoot, ".github");
164
+ const copilotInstructionsPath = join(githubDir, "copilot-instructions.md");
165
+
166
+ if (!existsSync(githubDir)) mkdirSync(githubDir, { recursive: true });
167
+
168
+ if (!existsSync(copilotInstructionsPath)) {
169
+ writeFileSync(copilotInstructionsPath, rendered, "utf8");
170
+ } else {
171
+ // Replace only the JENGA block; preserve content outside the markers
172
+ const existing = readFileSync(copilotInstructionsPath, "utf8");
173
+ const startMarker = "<!-- JENGA:START -->";
174
+ const endMarker = "<!-- JENGA:END -->";
175
+ const startIdx = existing.indexOf(startMarker);
176
+ const endIdx = existing.indexOf(endMarker);
177
+
178
+ // Extract the new JENGA block from rendered template
179
+ const renderedStart = rendered.indexOf(startMarker);
180
+ const renderedEnd = rendered.indexOf(endMarker);
181
+ const newBlock = rendered.slice(renderedStart, renderedEnd + endMarker.length);
182
+
183
+ let updated;
184
+ if (startIdx !== -1 && endIdx !== -1 && startIdx < endIdx) {
185
+ updated =
186
+ existing.slice(0, startIdx) +
187
+ newBlock +
188
+ existing.slice(endIdx + endMarker.length);
189
+ } else {
190
+ // No existing markers — append the block
191
+ updated = existing + (existing.endsWith("\n") ? "" : "\n") + newBlock + "\n";
192
+ }
193
+ writeFileSync(copilotInstructionsPath, updated, "utf8");
194
+ }
195
+ console.log("✓ .github/copilot-instructions.md written");
196
+ }
197
+ } catch (e) {
198
+ console.warn(`Warning: Could not write .github/copilot-instructions.md — ${e.message}`);
199
+ }
200
+
201
+ console.log('\nRun `jenga start` to start the router.');
202
+
203
+ return config;
204
+ } finally {
205
+ rl.close();
206
+ }
207
+ }
@@ -0,0 +1,16 @@
1
+ import { join, dirname } from "path";
2
+ import { fileURLToPath } from "url";
3
+
4
+ const __dirname = dirname(fileURLToPath(import.meta.url));
5
+ const projectRoot = join(__dirname, "..", "..");
6
+
7
+ export async function runStart(args, root = projectRoot) {
8
+ console.log(
9
+ "The Jenga Router is now started automatically by your AI client (e.g. Claude) when it opens a session.\n" +
10
+ "\n" +
11
+ "To set this up for your project, run:\n" +
12
+ " jenga attach\n" +
13
+ "\n" +
14
+ "Then open a new AI session in this project — the router will start automatically via stdio."
15
+ );
16
+ }
@@ -0,0 +1,53 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { join, dirname } from "path";
3
+ import { fileURLToPath } from "url";
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+ const projectRoot = join(__dirname, "..", "..");
7
+
8
+ function formatDuration(ms) {
9
+ const totalSeconds = Math.floor(ms / 1000);
10
+ const minutes = Math.floor(totalSeconds / 60);
11
+ const seconds = totalSeconds % 60;
12
+ if (minutes > 0) return `${minutes}m ${seconds}s`;
13
+ return `${seconds}s`;
14
+ }
15
+
16
+ function readSessionFile() {
17
+ const sessionFile = join(process.cwd(), ".jenga", "session.json");
18
+ try {
19
+ if (!existsSync(sessionFile)) return { skill: null, startedAt: null };
20
+ return JSON.parse(readFileSync(sessionFile, "utf8"));
21
+ } catch {
22
+ return { skill: null, startedAt: null };
23
+ }
24
+ }
25
+
26
+ export async function runStatus(args, root = projectRoot) {
27
+ const pidFile = join(process.cwd(), ".jenga", "router.pid");
28
+
29
+ // Check if router is running
30
+ if (!existsSync(pidFile)) {
31
+ console.log("Router is not running. Run `jenga start` to start it.");
32
+ process.exit(0);
33
+ }
34
+
35
+ let pid;
36
+ try {
37
+ pid = parseInt(readFileSync(pidFile, "utf8").trim(), 10);
38
+ process.kill(pid, 0); // throws if not alive
39
+ } catch {
40
+ console.log("Router is not running. Run `jenga start` to start it.");
41
+ process.exit(0);
42
+ }
43
+
44
+ // Read real session state from .jenga/session.json written by the router
45
+ const session = readSessionFile();
46
+
47
+ if (session.skill && session.startedAt) {
48
+ const elapsed = formatDuration(Date.now() - new Date(session.startedAt).getTime());
49
+ console.log(`✓ Router running (PID: ${pid}) | Active session: ${session.skill} (${elapsed})`);
50
+ } else {
51
+ console.log(`✓ Router running (PID: ${pid}) | No active session`);
52
+ }
53
+ }
@@ -0,0 +1,72 @@
1
+ import { readFileSync, existsSync } from "fs";
2
+ import { join } from "path";
3
+
4
+ export const configSchema = {
5
+ type: "object",
6
+ required: ["skillsPath", "matchThreshold", "sessionTimeout", "agentTarget"],
7
+ properties: {
8
+ // Single path (string) or multiple discovery paths (array of strings).
9
+ // Multi-target installs (e.g. claude + copilot) use an array holding
10
+ // both .claude/skills/ and .agents/skills/.
11
+ skillsPath: {
12
+ oneOf: [
13
+ { type: "string" },
14
+ { type: "array", items: { type: "string" }, minItems: 1 }
15
+ ]
16
+ },
17
+ matchThreshold: { type: "number", minimum: 0, maximum: 1 },
18
+ sessionTimeout: { type: "number", minimum: 0 },
19
+ agentTarget: {
20
+ oneOf: [
21
+ { type: "string", enum: ["claude", "copilot", "custom"] },
22
+ { type: "array", items: { type: "string", enum: ["claude", "copilot", "custom"] }, minItems: 1 }
23
+ ]
24
+ }
25
+ },
26
+ additionalProperties: false
27
+ };
28
+
29
+ export function validateConfig(config) {
30
+ for (const field of configSchema.required) {
31
+ if (config[field] === undefined || config[field] === null) {
32
+ return { valid: false, error: `Missing required field: ${field}` };
33
+ }
34
+ }
35
+ const skillsPathIsString = typeof config.skillsPath === "string";
36
+ const skillsPathIsArray = Array.isArray(config.skillsPath)
37
+ && config.skillsPath.length > 0
38
+ && config.skillsPath.every(p => typeof p === "string");
39
+ if (!skillsPathIsString && !skillsPathIsArray) {
40
+ return { valid: false, error: "skillsPath must be a string or a non-empty array of strings" };
41
+ }
42
+ if (typeof config.matchThreshold !== "number" || config.matchThreshold < 0 || config.matchThreshold > 1) {
43
+ return { valid: false, error: "matchThreshold must be a number between 0 and 1" };
44
+ }
45
+ if (typeof config.sessionTimeout !== "number" || config.sessionTimeout < 0) {
46
+ return { valid: false, error: "sessionTimeout must be a non-negative number" };
47
+ }
48
+ const validTargets = ["claude", "copilot", "custom"];
49
+ const targets = Array.isArray(config.agentTarget) ? config.agentTarget : [config.agentTarget];
50
+ if (targets.length === 0 || !targets.every(t => validTargets.includes(t))) {
51
+ return { valid: false, error: 'agentTarget must be one of: "claude", "copilot", "custom" (or an array of those values)' };
52
+ }
53
+ return { valid: true };
54
+ }
55
+
56
+ export function loadConfig(projectRoot = process.cwd()) {
57
+ const configPath = join(projectRoot, "jenga.cli.json");
58
+ if (!existsSync(configPath)) {
59
+ throw new Error(`jenga.cli.json not found at ${configPath}. Run 'jenga init' to create it.`);
60
+ }
61
+ let config;
62
+ try {
63
+ config = JSON.parse(readFileSync(configPath, "utf8"));
64
+ } catch (e) {
65
+ throw new Error(`Failed to parse jenga.cli.json: ${e.message}`);
66
+ }
67
+ const result = validateConfig(config);
68
+ if (!result.valid) {
69
+ throw new Error(result.error);
70
+ }
71
+ return config;
72
+ }
@@ -0,0 +1,61 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
2
+ import { join, resolve, dirname } from "path";
3
+ import { homedir } from "os";
4
+ import { fileURLToPath } from "url";
5
+
6
+ const HOOK_TYPE = "command";
7
+ // This file lives at <package>/lib/inject-settings.js — one level up is the
8
+ // installed jenga-agent package root, which is where hooks/prompt_router.sh
9
+ // ships. Consumers never need a local copy of the hook script.
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const PACKAGE_ROOT = join(__dirname, "..");
12
+
13
+ /**
14
+ * Injects the Jenga UserPromptSubmit hook into settings.json.
15
+ * Idempotent — running twice does not duplicate the entry.
16
+ *
17
+ * @param {string} projectRoot - project root directory (default: cwd)
18
+ * @param {boolean} global - if true, target ~/.claude/settings.json instead
19
+ */
20
+ export async function injectSettings(projectRoot = process.cwd(), global = false) {
21
+ const settingsDir = global
22
+ ? join(homedir(), ".claude")
23
+ : join(projectRoot, ".claude");
24
+ const settingsPath = join(settingsDir, "settings.json");
25
+
26
+ const hookCommand = resolve(PACKAGE_ROOT, "hooks", "prompt_router.sh");
27
+
28
+ // Load or create settings
29
+ let settings = {};
30
+ if (existsSync(settingsPath)) {
31
+ try {
32
+ settings = JSON.parse(readFileSync(settingsPath, "utf8"));
33
+ } catch (e) {
34
+ throw new Error(`Failed to parse ${settingsPath}: ${e.message}`);
35
+ }
36
+ }
37
+
38
+ // Ensure hooks.UserPromptSubmit exists
39
+ if (!settings.hooks) settings.hooks = {};
40
+ if (!settings.hooks.UserPromptSubmit) settings.hooks.UserPromptSubmit = [];
41
+
42
+ const hooks = settings.hooks.UserPromptSubmit;
43
+
44
+ // Check for existing Jenga entry (idempotency)
45
+ const alreadyRegistered = hooks.some(
46
+ (entry) =>
47
+ Array.isArray(entry.hooks) &&
48
+ entry.hooks.some((h) => h.type === HOOK_TYPE && h.command === hookCommand)
49
+ );
50
+
51
+ if (!alreadyRegistered) {
52
+ hooks.push({
53
+ matcher: "",
54
+ hooks: [{ type: HOOK_TYPE, command: hookCommand }],
55
+ });
56
+ }
57
+
58
+ mkdirSync(settingsDir, { recursive: true });
59
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
60
+ console.log(`✓ Hook registered in ${settingsPath}`);
61
+ }
package/lib/mirror.js ADDED
@@ -0,0 +1,244 @@
1
+ /**
2
+ * lib/mirror.js — Shared directory-mirroring helper
3
+ *
4
+ * Single source of truth for copying an explicit set of top-level entries
5
+ * (files or directories) from a source root into a destination root. Used
6
+ * by:
7
+ * - scripts/postinstall.js (consumer install; reconcileDeletes = false)
8
+ * - skills/self-sync/... (in-repo dev mirror; reconcileDeletes = true) [wired in T02]
9
+ *
10
+ * Design constraints:
11
+ * - ESM, Node built-ins only (node:fs / node:fs/promises / node:path).
12
+ * - Never writes outside `destRoot` — every destination path is boundary-
13
+ * checked with a resolved-path comparison.
14
+ * - Additive by default. Delete reconciliation only fires when the caller
15
+ * explicitly passes `reconcileDeletes: true`.
16
+ * - `dryRun` computes the plan (added / overwritten / deleted / skipped)
17
+ * without touching the filesystem.
18
+ */
19
+
20
+ import fs from 'node:fs';
21
+ import fsp from 'node:fs/promises';
22
+ import path from 'node:path';
23
+
24
+ // ── boundary check ────────────────────────────────────────────────────────────
25
+
26
+ /**
27
+ * Assert that `child` resolves to a path inside `parent`.
28
+ * Rejects `..` traversal, symlink escapes, and absolute overrides.
29
+ * Throws if the invariant is violated.
30
+ */
31
+ function assertInside(parent, child, label) {
32
+ const resolvedParent = path.resolve(parent);
33
+ const resolvedChild = path.resolve(child);
34
+ const rel = path.relative(resolvedParent, resolvedChild);
35
+ if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))) {
36
+ return; // inside (rel === '' means child === parent, which is fine)
37
+ }
38
+ throw new Error(
39
+ `mirror: refusing to write outside destRoot (${label}): ${resolvedChild} is outside ${resolvedParent}`
40
+ );
41
+ }
42
+
43
+ // ── content comparison ────────────────────────────────────────────────────────
44
+
45
+ /**
46
+ * Byte-compare two files. Returns true iff both exist and have identical
47
+ * contents. Any read error → false (treated as "differs, needs copy").
48
+ */
49
+ function filesEqual(a, b) {
50
+ try {
51
+ const statA = fs.statSync(a);
52
+ const statB = fs.statSync(b);
53
+ if (statA.size !== statB.size) return false;
54
+ const bufA = fs.readFileSync(a);
55
+ const bufB = fs.readFileSync(b);
56
+ return bufA.equals(bufB);
57
+ } catch (_) {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ // ── walk source, classify + copy ──────────────────────────────────────────────
63
+
64
+ /**
65
+ * Recursively walk `src` and mirror into `dst`, classifying each file.
66
+ * Mutates `result` in place. Honors `destRoot` boundary via assertInside.
67
+ */
68
+ function mirrorEntry(src, dst, destRoot, dryRun, excludeNames, result) {
69
+ if (!fs.existsSync(src)) return;
70
+
71
+ const srcStat = fs.statSync(src);
72
+
73
+ if (srcStat.isFile()) {
74
+ assertInside(destRoot, dst, 'file target');
75
+ const exists = fs.existsSync(dst);
76
+ if (!exists) {
77
+ result.added.push(dst);
78
+ if (!dryRun) {
79
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
80
+ fs.copyFileSync(src, dst);
81
+ }
82
+ } else if (!filesEqual(src, dst)) {
83
+ result.overwritten.push(dst);
84
+ if (!dryRun) {
85
+ fs.copyFileSync(src, dst);
86
+ }
87
+ } else {
88
+ result.skipped.push(dst);
89
+ }
90
+ return;
91
+ }
92
+
93
+ if (srcStat.isDirectory()) {
94
+ assertInside(destRoot, dst, 'dir target');
95
+ if (!dryRun) fs.mkdirSync(dst, { recursive: true });
96
+ const entries = fs.readdirSync(src, { withFileTypes: true });
97
+ for (const entry of entries) {
98
+ if (excludeNames.has(entry.name)) continue;
99
+ mirrorEntry(
100
+ path.join(src, entry.name),
101
+ path.join(dst, entry.name),
102
+ destRoot,
103
+ dryRun,
104
+ excludeNames,
105
+ result
106
+ );
107
+ }
108
+ }
109
+ // symlinks and other node types are intentionally ignored (parity with
110
+ // the old bash + node behavior, both of which used plain copy).
111
+ }
112
+
113
+ // ── delete reconciliation ─────────────────────────────────────────────────────
114
+
115
+ /**
116
+ * For each top-level entry in `copySet`, walk the destination subtree and
117
+ * delete any file/dir that is not present in the source subtree. Records
118
+ * the deletions in `result.deleted`. Boundary-checked. No-op unless
119
+ * `reconcileDeletes` was requested by the caller.
120
+ */
121
+ function reconcileEntry(src, dst, destRoot, dryRun, excludeNames, result) {
122
+ if (!fs.existsSync(dst)) return;
123
+
124
+ const dstStat = fs.statSync(dst);
125
+
126
+ // If the source is entirely absent, the whole destination entry is orphaned.
127
+ if (!fs.existsSync(src)) {
128
+ assertInside(destRoot, dst, 'orphan delete');
129
+ result.deleted.push(dst);
130
+ if (!dryRun) fs.rmSync(dst, { recursive: true, force: true });
131
+ return;
132
+ }
133
+
134
+ if (!dstStat.isDirectory()) {
135
+ // File-vs-file mismatches are handled by mirrorEntry; nothing to reconcile.
136
+ return;
137
+ }
138
+
139
+ const srcExists = fs.existsSync(src) && fs.statSync(src).isDirectory();
140
+ if (!srcExists) {
141
+ // src changed type (was a dir, now a file). Delete the whole mirror dir.
142
+ assertInside(destRoot, dst, 'type-change delete');
143
+ result.deleted.push(dst);
144
+ if (!dryRun) fs.rmSync(dst, { recursive: true, force: true });
145
+ return;
146
+ }
147
+
148
+ const dstEntries = fs.readdirSync(dst, { withFileTypes: true });
149
+ for (const entry of dstEntries) {
150
+ // Excluded names in the destination are treated as orphans and pruned —
151
+ // this is how the mirror recovers from prior copies made before the
152
+ // exclusion list existed (e.g. legacy `.agents/` nested inside `agents/`).
153
+ const childDst = path.join(dst, entry.name);
154
+ const childSrc = path.join(src, entry.name);
155
+ if (excludeNames.has(entry.name) || !fs.existsSync(childSrc)) {
156
+ assertInside(destRoot, childDst, 'orphan child delete');
157
+ result.deleted.push(childDst);
158
+ if (!dryRun) fs.rmSync(childDst, { recursive: true, force: true });
159
+ } else if (entry.isDirectory()) {
160
+ reconcileEntry(childSrc, childDst, destRoot, dryRun, excludeNames, result);
161
+ }
162
+ }
163
+ }
164
+
165
+ // ── public API ────────────────────────────────────────────────────────────────
166
+
167
+ /**
168
+ * Mirror an explicit set of top-level entries from `sourceRoot` into `destRoot`.
169
+ *
170
+ * @param {object} opts
171
+ * @param {string} opts.sourceRoot Absolute path to source root.
172
+ * @param {string} opts.destRoot Absolute path to destination root.
173
+ * @param {string[]} opts.copySet Relative paths (files or dirs).
174
+ * @param {boolean} [opts.dryRun=false] If true, compute plan without writing.
175
+ * @param {boolean} [opts.reconcileDeletes=false] If true, remove dest files/dirs
176
+ * not present in source (within copySet).
177
+ * @param {string[]} [opts.exclude=[]] Basenames to skip anywhere in the walk
178
+ * (e.g. ['.agents', '.claude', 'node_modules']).
179
+ * When reconcileDeletes is on, excluded
180
+ * basenames present in the destination
181
+ * are pruned as orphans.
182
+ * @returns {{added: string[], overwritten: string[], deleted: string[], skipped: string[]}}
183
+ * Absolute destination paths grouped by outcome.
184
+ */
185
+ export function mirror({
186
+ sourceRoot,
187
+ destRoot,
188
+ copySet,
189
+ dryRun = false,
190
+ reconcileDeletes = false,
191
+ exclude = [],
192
+ } = {}) {
193
+ if (!sourceRoot || typeof sourceRoot !== 'string') {
194
+ throw new TypeError('mirror: `sourceRoot` must be a non-empty string');
195
+ }
196
+ if (!destRoot || typeof destRoot !== 'string') {
197
+ throw new TypeError('mirror: `destRoot` must be a non-empty string');
198
+ }
199
+ if (!Array.isArray(copySet)) {
200
+ throw new TypeError('mirror: `copySet` must be an array of relative paths');
201
+ }
202
+ if (!Array.isArray(exclude)) {
203
+ throw new TypeError('mirror: `exclude` must be an array of basenames');
204
+ }
205
+
206
+ const resolvedSource = path.resolve(sourceRoot);
207
+ const resolvedDest = path.resolve(destRoot);
208
+ const excludeNames = new Set(exclude);
209
+
210
+ const result = {
211
+ added: [],
212
+ overwritten: [],
213
+ deleted: [],
214
+ skipped: [],
215
+ };
216
+
217
+ if (!dryRun) {
218
+ fs.mkdirSync(resolvedDest, { recursive: true });
219
+ }
220
+
221
+ for (const entry of copySet) {
222
+ if (typeof entry !== 'string' || entry.length === 0) {
223
+ throw new TypeError(`mirror: copySet entries must be non-empty strings (got ${JSON.stringify(entry)})`);
224
+ }
225
+ // Reject entries that would escape either root.
226
+ const src = path.join(resolvedSource, entry);
227
+ const dst = path.join(resolvedDest, entry);
228
+ assertInside(resolvedSource, src, `copySet source entry "${entry}"`);
229
+ assertInside(resolvedDest, dst, `copySet dest entry "${entry}"`);
230
+
231
+ mirrorEntry(src, dst, resolvedDest, dryRun, excludeNames, result);
232
+
233
+ if (reconcileDeletes) {
234
+ reconcileEntry(src, dst, resolvedDest, dryRun, excludeNames, result);
235
+ }
236
+ }
237
+
238
+ return result;
239
+ }
240
+
241
+ // Also export helpers for advanced callers / tests. Not part of the stable API.
242
+ export const __internal = { assertInside, filesEqual };
243
+
244
+ export default mirror;