kodelyth-ecc 1.5.10 → 1.7.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 (123) hide show
  1. package/.github/workflows/publish.yml +19 -0
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +447 -0
  4. package/CLAUDE.md +48 -16
  5. package/README.md +265 -81
  6. package/VERSION +1 -1
  7. package/actions/ecc-review/README.md +243 -0
  8. package/actions/ecc-review/action.yml +158 -0
  9. package/actions/ecc-review/post-comment.js +171 -0
  10. package/actions/ecc-review/run-review.js +285 -0
  11. package/agents/backdoor-hunter.md +260 -0
  12. package/agents/chaos-engineer.md +251 -0
  13. package/agents/code-stealer-detector.md +228 -0
  14. package/agents/jailbreak-tester.md +222 -0
  15. package/agents/license-violation-finder.md +212 -0
  16. package/agents/prompt-injection-hunter.md +126 -0
  17. package/agents/secret-hunter.md +209 -0
  18. package/agents/supply-chain-auditor.md +195 -0
  19. package/bin/kodelyth-ecc.js +875 -1
  20. package/bundles/enterprise.md +172 -0
  21. package/bundles/indie-hacker.md +106 -0
  22. package/bundles/red-team.md +138 -0
  23. package/cat +0 -0
  24. package/commands/dashboard.md +67 -0
  25. package/commands/devil-mode.md +121 -0
  26. package/commands/memory-evolve.md +71 -0
  27. package/commands/replay.md +61 -0
  28. package/commands/route-model.md +48 -0
  29. package/commands/swarm.md +68 -0
  30. package/commands/verify-supply-chain.md +59 -0
  31. package/docs/dashboard.md +211 -0
  32. package/docs/evolve.md +303 -0
  33. package/docs/mcp-clients.md +167 -0
  34. package/docs/mcp.md +178 -0
  35. package/docs/replay.md +244 -0
  36. package/docs/supply-chain.md +207 -0
  37. package/docs/swarm.md +243 -0
  38. package/hooks/hooks.json +52 -0
  39. package/hooks/memory/auto-recall.js +29 -1
  40. package/hooks/safety/README.md +124 -0
  41. package/hooks/safety/lib/patterns.js +179 -0
  42. package/hooks/safety/prompt-injection-guard.js +179 -0
  43. package/hooks/safety/token-budget.js +229 -0
  44. package/install.ps1 +199 -2
  45. package/install.sh +285 -3
  46. package/package.json +30 -4
  47. package/rules/common/agent-intent-routing.md +182 -0
  48. package/rules/common/cost-aware-model-routing.md +152 -0
  49. package/scripts/dashboard/data.js +353 -0
  50. package/scripts/dashboard/server.js +324 -0
  51. package/scripts/dashboard/static/index.html +645 -0
  52. package/scripts/evolve/analyze.js +303 -0
  53. package/scripts/evolve/proposals.js +162 -0
  54. package/scripts/evolve/stats.js +219 -0
  55. package/scripts/mcp/catalog.js +244 -0
  56. package/scripts/mcp/client.js +181 -0
  57. package/scripts/mcp/prompts.js +133 -0
  58. package/scripts/mcp/resources.js +94 -0
  59. package/scripts/mcp/server.js +153 -0
  60. package/scripts/mcp/tools.js +465 -0
  61. package/scripts/replay/bundle.js +191 -0
  62. package/scripts/replay/replay.js +107 -0
  63. package/scripts/router/classify.js +232 -0
  64. package/scripts/supply-chain/manifest.js +154 -0
  65. package/scripts/supply-chain/sbom.js +202 -0
  66. package/scripts/supply-chain/verify.js +102 -0
  67. package/scripts/swarm/build-plan.js +193 -0
  68. package/skills/cost-aware-model-routing/SKILL.md +153 -0
  69. package/skills/kodelyth-quickstart/SKILL.md +7 -0
  70. package/skills/observability-dashboard/SKILL.md +119 -0
  71. package/skills/self-evolving-memory/SKILL.md +175 -0
  72. package/skills/session-replay/SKILL.md +199 -0
  73. package/skills/supply-chain-verification/SKILL.md +201 -0
  74. package/skills/swarm-orchestrator/SKILL.md +177 -0
  75. package/social/card-install.svg +1 -1
  76. package/social/facebook-group/POST.md +121 -0
  77. package/social/facebook-group/fb-1-3am-debug.png +0 -0
  78. package/social/facebook-group/fb-1-3am-debug.svg +97 -0
  79. package/social/facebook-group/fb-2-cpu-upgrade.png +0 -0
  80. package/social/facebook-group/fb-2-cpu-upgrade.svg +132 -0
  81. package/social/facebook-group/fb-3-before-after.png +0 -0
  82. package/social/facebook-group/fb-3-before-after.svg +94 -0
  83. package/social/facebook-v150.svg +5 -5
  84. package/social/github-social-preview.svg +119 -100
  85. package/social/readme-hero.svg +11 -11
  86. package/social/section-agents.svg +57 -0
  87. package/social/section-author.svg +54 -0
  88. package/social/section-dashboard.svg +59 -0
  89. package/social/section-devil.svg +54 -0
  90. package/social/section-hooks.svg +51 -0
  91. package/social/section-install.svg +42 -0
  92. package/social/section-learning.svg +52 -0
  93. package/social/section-mcp.svg +46 -0
  94. package/social/section-memory.svg +57 -0
  95. package/social/section-parallel.svg +72 -0
  96. package/social/section-routing.svg +51 -0
  97. package/social/x-card-agents-grid.svg +6 -6
  98. package/tests/dashboard/data.test.js +235 -0
  99. package/tests/dashboard/server.test.js +240 -0
  100. package/tests/evolve/analyze.test.js +169 -0
  101. package/tests/evolve/proposals.test.js +173 -0
  102. package/tests/evolve/stats.test.js +159 -0
  103. package/tests/mcp/catalog.test.js +98 -0
  104. package/tests/mcp/client.test.js +109 -0
  105. package/tests/mcp/resources-prompts.test.js +70 -0
  106. package/tests/mcp/tools.test.js +159 -0
  107. package/tests/replay/bundle.test.js +181 -0
  108. package/tests/replay/replay.test.js +131 -0
  109. package/tests/router/classify.test.js +164 -0
  110. package/tests/safety/patterns.test.js +94 -0
  111. package/tests/safety/prompt-injection-guard.test.js +111 -0
  112. package/tests/safety/token-budget.test.js +119 -0
  113. package/tests/supply-chain/manifest.test.js +147 -0
  114. package/tests/supply-chain/sbom.test.js +170 -0
  115. package/tests/supply-chain/verify.test.js +146 -0
  116. package/tests/swarm/build-plan.test.js +188 -0
  117. package/wiki/Agent-Reference.md +58 -7
  118. package/wiki/FAQ.md +204 -7
  119. package/wiki/Home.md +104 -28
  120. package/wiki/Hook-Reference.md +1 -1
  121. package/wiki/Installation-Guide.md +109 -6
  122. package/wiki/Platform-Support.md +239 -25
  123. package/wiki/Skill-Reference.md +101 -6
@@ -0,0 +1,109 @@
1
+ // Tests for scripts/mcp/client.js — registry + connect helpers.
2
+ // Uses the LOCAL Kodelyth MCP server itself as a real test target so we
3
+ // exercise the full stdio handshake without needing external dependencies.
4
+ 'use strict';
5
+
6
+ const test = require('node:test');
7
+ const assert = require('node:assert/strict');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+
12
+ // Isolate the registry to a tmp dir so we don't pollute ~/.kodelyth.
13
+ const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'kodelyth-mcp-client-'));
14
+ process.env.KODELYTH_MCP_CLIENT_DIR = TMP;
15
+
16
+ const C = require('../../scripts/mcp/client.js');
17
+
18
+ const ROOT = path.join(__dirname, '..', '..');
19
+ const LOCAL_SERVER = path.join(ROOT, 'scripts', 'mcp', 'server.js');
20
+
21
+ // ── Registry tests (no SDK required) ─────────────────────────────────────────
22
+
23
+ test('registry: starts empty', () => {
24
+ const reg = C.loadRegistry();
25
+ assert.deepEqual(reg.servers, {});
26
+ assert.deepEqual(C.listServers(), []);
27
+ });
28
+
29
+ test('registry: addServer persists with sane defaults', () => {
30
+ const spec = C.addServer({
31
+ name: 'self',
32
+ command: process.execPath,
33
+ args: [LOCAL_SERVER],
34
+ env: { TEST_VAR: '1' },
35
+ description: 'local self-test target',
36
+ });
37
+ assert.equal(spec.name, 'self');
38
+ assert.equal(spec.command, process.execPath);
39
+ assert.deepEqual(spec.args, [LOCAL_SERVER]);
40
+ assert.equal(spec.env.TEST_VAR, '1');
41
+ assert.ok(spec.added_at);
42
+
43
+ // Persisted to disk:
44
+ assert.ok(fs.existsSync(C.REGISTRY_FILE));
45
+ const onDisk = JSON.parse(fs.readFileSync(C.REGISTRY_FILE, 'utf8'));
46
+ assert.equal(onDisk.servers.self.name, 'self');
47
+ });
48
+
49
+ test('registry: rejects bad names', () => {
50
+ assert.throws(() => C.addServer({ name: '', command: 'x' }));
51
+ assert.throws(() => C.addServer({ name: 'has space',command: 'x' }));
52
+ assert.throws(() => C.addServer({ name: 'self', command: '' }));
53
+ });
54
+
55
+ test('registry: getServer / listServers reflect adds + removes', () => {
56
+ C.addServer({ name: 'second', command: '/usr/bin/true', args: [] });
57
+ const list = C.listServers();
58
+ assert.equal(list.length, 2, `expected 2, got ${list.length}`);
59
+ const names = list.map(s => s.name).sort();
60
+ assert.deepEqual(names, ['second', 'self']);
61
+
62
+ assert.ok(C.getServer('self'));
63
+ assert.equal(C.getServer('not-real'), null);
64
+
65
+ assert.equal(C.removeServer('second'), true);
66
+ assert.equal(C.removeServer('second'), false); // already gone
67
+ assert.equal(C.listServers().length, 1);
68
+ });
69
+
70
+ // ── End-to-end: connect to local kodelyth MCP server and call tools ──────────
71
+
72
+ test('client: connects to local kodelyth MCP server and lists tools', async () => {
73
+ // self is already registered from the earlier test (running in same file).
74
+ const out = await C.listTools('self');
75
+ assert.ok(out.tools.length > 0, 'expected at least one tool from local kodelyth server');
76
+ const names = new Set(out.tools.map(t => t.name));
77
+ assert.ok(names.has('catalog_stats'), 'catalog_stats tool must be exposed');
78
+ assert.ok(names.has('list_agents'), 'list_agents tool must be exposed');
79
+ });
80
+
81
+ test('client: callTool on local server returns a real result', async () => {
82
+ const out = await C.callTool('self', 'catalog_stats', {});
83
+ assert.ok(Array.isArray(out.content));
84
+ assert.equal(out.content[0].type, 'text');
85
+ const data = JSON.parse(out.content[0].text);
86
+ assert.ok(data.agents > 0);
87
+ assert.ok(data.skills > 0);
88
+ assert.ok(data.commands > 0);
89
+ });
90
+
91
+ test('client: listResources surfaces kodelyth:// URIs', async () => {
92
+ const out = await C.listResources('self');
93
+ assert.ok(out.resources.length > 0);
94
+ assert.ok(out.resources.every(r => r.uri.startsWith('kodelyth://')),
95
+ 'every resource uri should start with kodelyth://');
96
+ });
97
+
98
+ test('client: listPrompts surfaces routing-rule', async () => {
99
+ const out = await C.listPrompts('self');
100
+ const names = new Set(out.prompts.map(p => p.name));
101
+ assert.ok(names.has('routing-rule'));
102
+ });
103
+
104
+ test('client: connect fails clearly for unregistered server', async () => {
105
+ await assert.rejects(
106
+ () => C.connect('this-was-never-registered'),
107
+ err => err.code === 'MCP_SERVER_NOT_REGISTERED'
108
+ );
109
+ });
@@ -0,0 +1,70 @@
1
+ // Tests for scripts/mcp/resources.js and scripts/mcp/prompts.js
2
+ 'use strict';
3
+
4
+ const test = require('node:test');
5
+ const assert = require('node:assert/strict');
6
+
7
+ const RES = require('../../scripts/mcp/resources');
8
+ const PROMPTS = require('../../scripts/mcp/prompts');
9
+
10
+ test('resources.buildList returns agents/skills/commands/rules/bundles', () => {
11
+ const list = RES.buildList();
12
+ assert.ok(list.length > 200, `expected >200 resources, got ${list.length}`);
13
+ const schemes = new Set(list.map(r => r.uri.split('://')[1].split('/')[0]));
14
+ for (const expected of ['agents', 'skills', 'commands', 'rules', 'bundles']) {
15
+ assert.ok(schemes.has(expected), `missing resource bucket: ${expected}`);
16
+ }
17
+ for (const r of list.slice(0, 5)) {
18
+ assert.match(r.uri, /^kodelyth:\/\//);
19
+ assert.equal(r.mimeType, 'text/markdown');
20
+ }
21
+ });
22
+
23
+ test('resources.readResource returns body for a known agent', () => {
24
+ const out = RES.readResource('kodelyth://agents/debug-detective');
25
+ assert.ok(out.contents.length === 1);
26
+ assert.equal(out.contents[0].mimeType, 'text/markdown');
27
+ assert.match(out.contents[0].text, /debug-detective/);
28
+ });
29
+
30
+ test('resources.readResource rejects unknown URI scheme', () => {
31
+ assert.throws(() => RES.readResource('weird://foo/bar'));
32
+ });
33
+
34
+ test('resources.readResource rejects unknown name', () => {
35
+ assert.throws(() => RES.readResource('kodelyth://agents/no-such-agent'));
36
+ });
37
+
38
+ test('PROMPT_DEFINITIONS exposes routing-rule and overviews', () => {
39
+ const names = PROMPTS.PROMPT_DEFINITIONS.map(p => p.name);
40
+ for (const expected of [
41
+ 'routing-rule',
42
+ 'agents-overview',
43
+ 'skills-overview',
44
+ 'commands-overview',
45
+ 'handoff-chains',
46
+ 'devil-mode',
47
+ ]) {
48
+ assert.ok(names.includes(expected), `missing prompt: ${expected}`);
49
+ }
50
+ });
51
+
52
+ test('routing-rule prompt returns the full intent routing rule body', () => {
53
+ const out = PROMPTS.PROMPT_HANDLERS['routing-rule']();
54
+ assert.ok(out.messages.length === 1);
55
+ assert.equal(out.messages[0].role, 'user');
56
+ assert.match(out.messages[0].content.text, /Agent Intent Routing/);
57
+ });
58
+
59
+ test('agents-overview returns a summary of all agents', () => {
60
+ const out = PROMPTS.PROMPT_HANDLERS['agents-overview']();
61
+ const text = out.messages[0].content.text;
62
+ assert.match(text, /Kodelyth ECC — Agents/);
63
+ assert.match(text, /debug-detective/);
64
+ });
65
+
66
+ test('devil-mode prompt returns the parallel command body', () => {
67
+ const out = PROMPTS.PROMPT_HANDLERS['devil-mode']();
68
+ const text = out.messages[0].content.text;
69
+ assert.match(text, /devil-mode/i);
70
+ });
@@ -0,0 +1,159 @@
1
+ // Tests for scripts/mcp/tools.js — pure-function MCP tool handlers.
2
+ 'use strict';
3
+
4
+ const test = require('node:test');
5
+ const assert = require('node:assert/strict');
6
+ const fs = require('fs');
7
+ const os = require('os');
8
+ const path = require('path');
9
+
10
+ // Isolate the memory store to a tmp dir so capture/recall don't pollute
11
+ // the user's real ~/.kodelyth/memory.
12
+ const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'kodelyth-mcp-'));
13
+ process.env.KODELYTH_MEMORY_DIR = TMP;
14
+
15
+ // Load AFTER setting env so the memory store picks up the tmp dir.
16
+ const T = require('../../scripts/mcp/tools');
17
+
18
+ function parseJSON(toolResult) {
19
+ const text = toolResult.content[0].text;
20
+ return JSON.parse(text);
21
+ }
22
+
23
+ test('TOOL_DEFINITIONS exposes the expected tool surface', () => {
24
+ const names = T.TOOL_DEFINITIONS.map(t => t.name);
25
+ for (const expected of [
26
+ 'route_intent',
27
+ 'list_agents',
28
+ 'list_skills',
29
+ 'list_commands',
30
+ 'list_rules',
31
+ 'list_bundles',
32
+ 'get_agent',
33
+ 'get_skill',
34
+ 'get_command',
35
+ 'get_rule',
36
+ 'get_bundle',
37
+ 'recall_memory',
38
+ 'capture_memory',
39
+ 'memory_stats',
40
+ 'catalog_stats',
41
+ 'audit_skill_match',
42
+ ]) {
43
+ assert.ok(names.includes(expected), `missing tool: ${expected}`);
44
+ }
45
+ // Every tool has a handler entry.
46
+ for (const t of T.TOOL_DEFINITIONS) {
47
+ assert.equal(typeof T.TOOL_HANDLERS[t.name], 'function', `${t.name} handler missing`);
48
+ assert.ok(t.description && t.description.length > 5, `${t.name} description too short`);
49
+ assert.ok(t.inputSchema && t.inputSchema.type === 'object', `${t.name} inputSchema malformed`);
50
+ }
51
+ });
52
+
53
+ test('route_intent rejects empty message', () => {
54
+ const r = T.tool_route_intent({});
55
+ assert.ok(r.isError, 'must error on missing message');
56
+ });
57
+
58
+ test('route_intent surfaces a relevant agent for a debug task', () => {
59
+ const r = T.tool_route_intent({ message: 'i have a hard intermittent bug in production' });
60
+ const data = parseJSON(r);
61
+ assert.ok(Array.isArray(data.suggestions));
62
+ assert.ok(data.suggestions.length > 0, 'should produce at least one suggestion');
63
+ });
64
+
65
+ test('route_intent honours top_k', () => {
66
+ const r = T.tool_route_intent({ message: 'review this code for security and quality issues', top_k: 2 });
67
+ const data = parseJSON(r);
68
+ assert.ok(data.suggestions.length <= 2);
69
+ });
70
+
71
+ test('list_agents returns the full catalog', () => {
72
+ const r = T.tool_list_agents();
73
+ const data = parseJSON(r);
74
+ assert.ok(data.count >= 60);
75
+ assert.ok(Array.isArray(data.agents) && data.agents.length === data.count);
76
+ });
77
+
78
+ test('get_agent returns body for known agent', () => {
79
+ const r = T.tool_get_agent({ name: 'debug-detective' });
80
+ assert.ok(!r.isError);
81
+ assert.match(r.content[0].text, /^# Agent: debug-detective/);
82
+ });
83
+
84
+ test('get_agent errors on unknown agent', () => {
85
+ const r = T.tool_get_agent({ name: 'no-such-agent' });
86
+ assert.ok(r.isError);
87
+ });
88
+
89
+ test('get_command tolerates leading slash', () => {
90
+ const r = T.tool_get_command({ name: '/code-review' });
91
+ assert.ok(!r.isError);
92
+ assert.match(r.content[0].text, /^# Command: \/code-review/);
93
+ });
94
+
95
+ test('get_rule returns the intent routing rule', () => {
96
+ const r = T.tool_get_rule({ name: 'agent-intent-routing' });
97
+ assert.ok(!r.isError);
98
+ assert.match(r.content[0].text, /Agent Intent Routing/);
99
+ });
100
+
101
+ test('get_bundle returns red-team cheat sheet', () => {
102
+ const r = T.tool_get_bundle({ name: 'red-team' });
103
+ assert.ok(!r.isError);
104
+ assert.ok(r.content[0].text.length > 100);
105
+ });
106
+
107
+ test('catalog_stats returns counts', () => {
108
+ const r = T.tool_catalog_stats();
109
+ const s = parseJSON(r);
110
+ assert.ok(s.agents > 0 && s.skills > 0 && s.commands > 0);
111
+ });
112
+
113
+ test('audit_skill_match suggests at least one skill for an observability task', () => {
114
+ const r = T.tool_audit_skill_match({ task: 'add structured logging and tracing to a fastapi service' });
115
+ const data = parseJSON(r);
116
+ assert.ok(Array.isArray(data.suggestions));
117
+ assert.ok(data.suggestions.length > 0, 'expected at least one skill suggestion');
118
+ });
119
+
120
+ test('capture_memory + recall_memory roundtrip in isolated tmp store', () => {
121
+ // Pre-seed a few unrelated memories so BM25 IDF can rise above the store's
122
+ // default minScore floor (with N=1 IDF stays ~0.29 which is filtered out).
123
+ for (const seed of [
124
+ { problem: 'fix a flaky CI build on github actions', approach: 'set timeout to 60s and retry once' },
125
+ { problem: 'wire up redis caching for product list', approach: 'use 60s ttl plus stale-while-revalidate' },
126
+ { problem: 'migrate postgres timestamps to utc', approach: 'add at::timestamptz coercion in alembic migration' },
127
+ ]) {
128
+ T.tool_capture_memory({ ...seed, tags: ['seed'], language: 'misc' });
129
+ }
130
+
131
+ const cap = T.tool_capture_memory({
132
+ problem: 'mcp test problem keyword splatzilla',
133
+ approach: 'documenting that capture_memory works end-to-end',
134
+ tags: ['mcp', 'test'],
135
+ language: 'javascript',
136
+ });
137
+ assert.ok(!cap.isError, 'capture must succeed');
138
+ const capData = parseJSON(cap);
139
+ assert.ok(capData.id && typeof capData.id === 'string');
140
+ assert.ok(capData.captured_at);
141
+
142
+ const rec = T.tool_recall_memory({ query: 'splatzilla', limit: 3 });
143
+ assert.ok(!rec.isError);
144
+ const recData = parseJSON(rec);
145
+ assert.ok(recData.count >= 1, 'should recall the memory we just captured');
146
+ assert.equal(recData.memories[0].problem, 'mcp test problem keyword splatzilla');
147
+ });
148
+
149
+ test('capture_memory rejects missing required fields', () => {
150
+ const r = T.tool_capture_memory({ problem: 'only problem, no approach' });
151
+ assert.ok(r.isError);
152
+ });
153
+
154
+ test('memory_stats reflects at least one stored memory', () => {
155
+ const r = T.tool_memory_stats();
156
+ assert.ok(!r.isError);
157
+ const s = parseJSON(r);
158
+ assert.ok(typeof s === 'object');
159
+ });
@@ -0,0 +1,181 @@
1
+ // Tests for scripts/replay/bundle.js — pure read/write of session bundles.
2
+ 'use strict';
3
+
4
+ const test = require('node:test');
5
+ const assert = require('node:assert/strict');
6
+ const fs = require('fs');
7
+ const os = require('os');
8
+ const path = require('path');
9
+
10
+ const B = require('../../scripts/replay/bundle.js');
11
+
12
+ function tmp() {
13
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'kodelyth-bundle-'));
14
+ }
15
+
16
+ function seedSession(root, sessionName, workers) {
17
+ const sessionDir = path.join(root, sessionName);
18
+ fs.mkdirSync(sessionDir, { recursive: true });
19
+ for (const w of workers) {
20
+ const wdir = path.join(sessionDir, w.slug);
21
+ fs.mkdirSync(wdir);
22
+ fs.writeFileSync(path.join(wdir, 'task.md'), w.task || `# task for ${w.slug}`);
23
+ fs.writeFileSync(path.join(wdir, 'handoff.md'), w.handoff || `# handoff for ${w.slug}`);
24
+ fs.writeFileSync(path.join(wdir, 'status.md'), w.status || `# status for ${w.slug}\n- State: completed`);
25
+ }
26
+ return sessionDir;
27
+ }
28
+
29
+ // ── exportBundle ─────────────────────────────────────────────────────────────
30
+
31
+ test('exportBundle: rejects missing session dir', () => {
32
+ assert.throws(() => B.exportBundle({ sessionDir: '/no/such/path' }), /not found/);
33
+ });
34
+
35
+ test('exportBundle: gathers task/handoff/status per worker', () => {
36
+ const root = tmp();
37
+ const dir = seedSession(root, 'swarm-test', [
38
+ { slug: 'security-reviewer', task: 'audit', handoff: 'found 3 issues', status: 'completed' },
39
+ { slug: 'code-reviewer', task: 'review', handoff: 'all good', status: 'completed' },
40
+ ]);
41
+ const bundle = B.exportBundle({ sessionDir: dir, sessionName: 'swarm-test', meta: { task: 'audit oauth', harness: 'claude' } });
42
+ assert.equal(bundle.schema, B.BUNDLE_SCHEMA);
43
+ assert.equal(bundle.session, 'swarm-test');
44
+ assert.equal(bundle.workers.length, 2);
45
+ assert.equal(bundle.meta.task, 'audit oauth');
46
+ // Sorted by slug:
47
+ assert.deepEqual(bundle.workers.map(w => w.slug), ['code-reviewer', 'security-reviewer']);
48
+ // Content preserved:
49
+ const sec = bundle.workers.find(w => w.slug === 'security-reviewer');
50
+ assert.equal(sec.task, 'audit');
51
+ assert.equal(sec.handoff, 'found 3 issues');
52
+ });
53
+
54
+ test('exportBundle: handles empty session dir gracefully', () => {
55
+ const root = tmp();
56
+ const empty = path.join(root, 'empty');
57
+ fs.mkdirSync(empty);
58
+ const bundle = B.exportBundle({ sessionDir: empty });
59
+ assert.equal(bundle.workers.length, 0);
60
+ });
61
+
62
+ // ── writeBundle / readBundle round-trip ──────────────────────────────────────
63
+
64
+ test('writeBundle + readBundle: round-trips losslessly', () => {
65
+ const root = tmp();
66
+ const dir = seedSession(root, 's1', [{ slug: 'a' }, { slug: 'b' }]);
67
+ const bundle = B.exportBundle({ sessionDir: dir, meta: { task: 'foo' } });
68
+ const out = path.join(root, 'out.json');
69
+ B.writeBundle(bundle, out);
70
+
71
+ const reread = B.readBundle(out);
72
+ assert.equal(reread.session, bundle.session);
73
+ assert.equal(reread.workers.length, 2);
74
+ assert.equal(reread.meta.task, 'foo');
75
+ });
76
+
77
+ test('writeBundle: rejects bad schema', () => {
78
+ assert.throws(() => B.writeBundle({ schema: 'bogus' }, '/tmp/x.json'), /invalid schema/);
79
+ });
80
+
81
+ test('readBundle: rejects unknown schema', () => {
82
+ const root = tmp();
83
+ const out = path.join(root, 'bad.json');
84
+ fs.writeFileSync(out, JSON.stringify({ schema: 'not.kodelyth/v0', session: 'x', workers: [] }));
85
+ assert.throws(() => B.readBundle(out), /unsupported schema/);
86
+ });
87
+
88
+ test('readBundle: rejects empty workers', () => {
89
+ const root = tmp();
90
+ const out = path.join(root, 'empty.json');
91
+ fs.writeFileSync(out, JSON.stringify({ schema: B.BUNDLE_SCHEMA, session: 'x', workers: [] }));
92
+ assert.throws(() => B.readBundle(out), /non-empty array/);
93
+ });
94
+
95
+ // ── importBundle ─────────────────────────────────────────────────────────────
96
+
97
+ test('importBundle: restores worker files into target dir', () => {
98
+ const root = tmp();
99
+ const dir = seedSession(root, 'src', [
100
+ { slug: 'a', task: 'task-a', handoff: 'handoff-a', status: 'status-a' },
101
+ ]);
102
+ const bundle = B.exportBundle({ sessionDir: dir });
103
+
104
+ const target = path.join(root, 'restored');
105
+ const out = B.importBundle(bundle, { targetDir: target });
106
+ assert.equal(out.targetDir, target);
107
+ assert.deepEqual(out.workers, ['a']);
108
+
109
+ assert.equal(fs.readFileSync(path.join(target, 'a', 'task.md'), 'utf8'), 'task-a');
110
+ assert.equal(fs.readFileSync(path.join(target, 'a', 'handoff.md'), 'utf8'), 'handoff-a');
111
+ assert.equal(fs.readFileSync(path.join(target, 'a', 'status.md'), 'utf8'), 'status-a');
112
+ });
113
+
114
+ test('importBundle: refuses to overwrite without flag', () => {
115
+ const root = tmp();
116
+ const dir = seedSession(root, 'src', [{ slug: 'a' }]);
117
+ const bundle = B.exportBundle({ sessionDir: dir });
118
+ const target = path.join(root, 'exists');
119
+ fs.mkdirSync(target);
120
+ assert.throws(() => B.importBundle(bundle, { targetDir: target }), /already exists/);
121
+ });
122
+
123
+ test('importBundle: overwrite=true replaces existing dir', () => {
124
+ const root = tmp();
125
+ const dir = seedSession(root, 'src', [{ slug: 'a', task: 'fresh' }]);
126
+ const bundle = B.exportBundle({ sessionDir: dir });
127
+ const target = path.join(root, 'exists');
128
+ fs.mkdirSync(target);
129
+ fs.writeFileSync(path.join(target, 'stale.txt'), 'leftover');
130
+
131
+ B.importBundle(bundle, { targetDir: target, overwrite: true });
132
+ assert.equal(fs.existsSync(path.join(target, 'stale.txt')), false);
133
+ assert.equal(fs.readFileSync(path.join(target, 'a', 'task.md'), 'utf8'), 'fresh');
134
+ });
135
+
136
+ // ── diffBundles ──────────────────────────────────────────────────────────────
137
+
138
+ test('diffBundles: detects handoff changes', () => {
139
+ const root = tmp();
140
+ const a = B.exportBundle({ sessionDir: seedSession(root, 'a', [
141
+ { slug: 'x', handoff: 'aaa' }, { slug: 'y', handoff: 'shared' },
142
+ ]) });
143
+ const b = B.exportBundle({ sessionDir: seedSession(root, 'b', [
144
+ { slug: 'x', handoff: 'bbb' }, { slug: 'y', handoff: 'shared' },
145
+ ]) });
146
+ const d = B.diffBundles(a, b);
147
+ const x = d.find(r => r.slug === 'x');
148
+ const y = d.find(r => r.slug === 'y');
149
+ assert.equal(x.handoff_changed, true);
150
+ assert.equal(y.handoff_changed, false);
151
+ });
152
+
153
+ test('diffBundles: surfaces workers that exist on only one side', () => {
154
+ const root = tmp();
155
+ const a = B.exportBundle({ sessionDir: seedSession(root, 'a', [
156
+ { slug: 'only-a' }, { slug: 'shared' },
157
+ ]) });
158
+ const b = B.exportBundle({ sessionDir: seedSession(root, 'b', [
159
+ { slug: 'shared' }, { slug: 'only-b' },
160
+ ]) });
161
+ const d = B.diffBundles(a, b);
162
+ assert.equal(d.find(r => r.slug === 'only-a').in_a, true);
163
+ assert.equal(d.find(r => r.slug === 'only-a').in_b, false);
164
+ assert.equal(d.find(r => r.slug === 'only-b').in_b, true);
165
+ assert.equal(d.find(r => r.slug === 'only-b').in_a, false);
166
+ });
167
+
168
+ // ── nextReplayName ───────────────────────────────────────────────────────────
169
+
170
+ test('nextReplayName: generates -replay-1 for fresh origin', () => {
171
+ assert.equal(B.nextReplayName('swarm-2026-05-10-4a'), 'swarm-2026-05-10-4a-replay-1');
172
+ });
173
+
174
+ test('nextReplayName: skips taken numbers', () => {
175
+ const taken = new Set(['swarm-x-replay-1', 'swarm-x-replay-2']);
176
+ assert.equal(B.nextReplayName('swarm-x', taken), 'swarm-x-replay-3');
177
+ });
178
+
179
+ test('nextReplayName: strips existing -replay-N suffix to find canonical base', () => {
180
+ assert.equal(B.nextReplayName('swarm-x-replay-3'), 'swarm-x-replay-1');
181
+ });
@@ -0,0 +1,131 @@
1
+ // Tests for scripts/replay/replay.js — replay engine.
2
+ 'use strict';
3
+
4
+ const test = require('node:test');
5
+ const assert = require('node:assert/strict');
6
+ const path = require('path');
7
+
8
+ const R = require('../../scripts/replay/replay.js');
9
+ const B = require('../../scripts/replay/bundle.js');
10
+ const O = require('../../scripts/lib/tmux-worktree-orchestrator.js');
11
+
12
+ const FAKE_BUNDLE = {
13
+ schema: B.BUNDLE_SCHEMA,
14
+ session: 'swarm-2026-05-10-4a',
15
+ exported_at: '2026-05-10T17:00:00Z',
16
+ exported_by: 'kodelyth-ecc@1.7.0',
17
+ meta: {
18
+ task: 'audit oauth flow for security regressions',
19
+ agents: ['security-reviewer', 'code-reviewer'],
20
+ harness: 'echo',
21
+ base_ref: 'HEAD',
22
+ },
23
+ workers: [
24
+ { slug: 'security-reviewer', task: '# task', handoff: '# handoff', status: '# status' },
25
+ { slug: 'code-reviewer', task: '# task', handoff: '# handoff', status: '# status' },
26
+ ],
27
+ };
28
+
29
+ // ── extractTaskFromBundle ────────────────────────────────────────────────────
30
+
31
+ test('extractTaskFromBundle: prefers meta.task when present', () => {
32
+ const t = R.extractTaskFromBundle(FAKE_BUNDLE);
33
+ assert.equal(t, 'audit oauth flow for security regressions');
34
+ });
35
+
36
+ test('extractTaskFromBundle: falls back to parsing first worker task.md (Shared Task)', () => {
37
+ const bundle = {
38
+ schema: B.BUNDLE_SCHEMA,
39
+ session: 's',
40
+ workers: [{
41
+ slug: 'a',
42
+ task: '# a — swarm task\n\nblah\n\n## Shared Task\n\nrefactor payments\n\n## Required handoff sections\n1. Summary',
43
+ }],
44
+ meta: {},
45
+ };
46
+ assert.equal(R.extractTaskFromBundle(bundle), 'refactor payments');
47
+ });
48
+
49
+ test('extractTaskFromBundle: falls back to orchestrator Objective block', () => {
50
+ const bundle = {
51
+ schema: B.BUNDLE_SCHEMA,
52
+ session: 's',
53
+ workers: [{
54
+ slug: 'a',
55
+ task: '# Worker Task\n\n## Objective\nfix the bug\n\n## Completion\nDo not spawn',
56
+ }],
57
+ meta: {},
58
+ };
59
+ assert.equal(R.extractTaskFromBundle(bundle), 'fix the bug');
60
+ });
61
+
62
+ test('extractTaskFromBundle: throws if no task can be recovered', () => {
63
+ const bundle = { schema: B.BUNDLE_SCHEMA, session: 's', workers: [], meta: {} };
64
+ assert.throws(() => R.extractTaskFromBundle(bundle), /cannot extract task/);
65
+ });
66
+
67
+ // ── buildReplayPlanConfig ────────────────────────────────────────────────────
68
+
69
+ test('buildReplayPlanConfig: defaults to bundle.meta values', () => {
70
+ const cfg = R.buildReplayPlanConfig(FAKE_BUNDLE, { repoRoot: process.cwd() });
71
+ assert.equal(cfg._meta.task, 'audit oauth flow for security regressions');
72
+ assert.deepEqual(cfg._meta.pickedAgents, ['security-reviewer', 'code-reviewer']);
73
+ assert.match(cfg.sessionName, /^swarm-2026-05-10-4a-replay-1$/);
74
+ assert.equal(cfg.baseRef, 'HEAD');
75
+ });
76
+
77
+ test('buildReplayPlanConfig: --harness override flows to plan', () => {
78
+ const cfg = R.buildReplayPlanConfig(FAKE_BUNDLE, { repoRoot: process.cwd(), harness: 'echo' });
79
+ // Harness override is preserved in the planConfig._meta.
80
+ assert.equal(cfg._meta.harness, 'echo');
81
+ });
82
+
83
+ test('buildReplayPlanConfig: --agents override replaces bundle.meta.agents', () => {
84
+ const cfg = R.buildReplayPlanConfig(FAKE_BUNDLE, {
85
+ repoRoot: process.cwd(),
86
+ agents: ['supply-chain-auditor', 'prompt-injection-hunter'],
87
+ });
88
+ assert.deepEqual(cfg._meta.pickedAgents, ['supply-chain-auditor', 'prompt-injection-hunter']);
89
+ });
90
+
91
+ test('buildReplayPlanConfig: --base-ref override flows to plan', () => {
92
+ const cfg = R.buildReplayPlanConfig(FAKE_BUNDLE, { repoRoot: process.cwd(), baseRef: 'main' });
93
+ assert.equal(cfg.baseRef, 'main');
94
+ });
95
+
96
+ test('buildReplayPlanConfig: --session override wins over auto-naming', () => {
97
+ const cfg = R.buildReplayPlanConfig(FAKE_BUNDLE, {
98
+ repoRoot: process.cwd(),
99
+ sessionName: 'my-replay',
100
+ });
101
+ assert.equal(cfg.sessionName, 'my-replay');
102
+ });
103
+
104
+ test('buildReplayPlanConfig: takenSessions skips conflicting names', () => {
105
+ const taken = new Set(['swarm-2026-05-10-4a-replay-1', 'swarm-2026-05-10-4a-replay-2']);
106
+ const cfg = R.buildReplayPlanConfig(FAKE_BUNDLE, { repoRoot: process.cwd(), takenSessions: taken });
107
+ assert.equal(cfg.sessionName, 'swarm-2026-05-10-4a-replay-3');
108
+ });
109
+
110
+ test('buildReplayPlanConfig: produces a plan-config the orchestrator accepts', () => {
111
+ const cfg = R.buildReplayPlanConfig(FAKE_BUNDLE, { repoRoot: process.cwd() });
112
+ // Verify orchestrator can build a real plan from it.
113
+ const plan = O.buildOrchestrationPlan(cfg);
114
+ assert.equal(plan.workerPlans.length, 2);
115
+ assert.ok(plan.workerPlans.every(w => w.task.includes('audit oauth flow')));
116
+ assert.match(plan.sessionName, /-replay-/);
117
+ });
118
+
119
+ test('buildReplayPlanConfig: falls back to worker slugs when no meta.agents AND no override', () => {
120
+ const minimalBundle = {
121
+ schema: B.BUNDLE_SCHEMA,
122
+ session: 'minimal',
123
+ workers: [
124
+ { slug: 'a', task: '## Shared Task\n\ndo X\n\n## Required handoff sections\n' },
125
+ { slug: 'b', task: '## Shared Task\n\ndo X\n\n## Required handoff sections\n' },
126
+ ],
127
+ meta: {},
128
+ };
129
+ const cfg = R.buildReplayPlanConfig(minimalBundle, { repoRoot: process.cwd(), harness: 'echo' });
130
+ assert.deepEqual(cfg._meta.pickedAgents, ['a', 'b']);
131
+ });