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.
- package/.github/workflows/publish.yml +19 -0
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +447 -0
- package/CLAUDE.md +48 -16
- package/README.md +265 -81
- package/VERSION +1 -1
- package/actions/ecc-review/README.md +243 -0
- package/actions/ecc-review/action.yml +158 -0
- package/actions/ecc-review/post-comment.js +171 -0
- package/actions/ecc-review/run-review.js +285 -0
- package/agents/backdoor-hunter.md +260 -0
- package/agents/chaos-engineer.md +251 -0
- package/agents/code-stealer-detector.md +228 -0
- package/agents/jailbreak-tester.md +222 -0
- package/agents/license-violation-finder.md +212 -0
- package/agents/prompt-injection-hunter.md +126 -0
- package/agents/secret-hunter.md +209 -0
- package/agents/supply-chain-auditor.md +195 -0
- package/bin/kodelyth-ecc.js +875 -1
- package/bundles/enterprise.md +172 -0
- package/bundles/indie-hacker.md +106 -0
- package/bundles/red-team.md +138 -0
- package/cat +0 -0
- package/commands/dashboard.md +67 -0
- package/commands/devil-mode.md +121 -0
- package/commands/memory-evolve.md +71 -0
- package/commands/replay.md +61 -0
- package/commands/route-model.md +48 -0
- package/commands/swarm.md +68 -0
- package/commands/verify-supply-chain.md +59 -0
- package/docs/dashboard.md +211 -0
- package/docs/evolve.md +303 -0
- package/docs/mcp-clients.md +167 -0
- package/docs/mcp.md +178 -0
- package/docs/replay.md +244 -0
- package/docs/supply-chain.md +207 -0
- package/docs/swarm.md +243 -0
- package/hooks/hooks.json +52 -0
- package/hooks/memory/auto-recall.js +29 -1
- package/hooks/safety/README.md +124 -0
- package/hooks/safety/lib/patterns.js +179 -0
- package/hooks/safety/prompt-injection-guard.js +179 -0
- package/hooks/safety/token-budget.js +229 -0
- package/install.ps1 +199 -2
- package/install.sh +285 -3
- package/package.json +30 -4
- package/rules/common/agent-intent-routing.md +182 -0
- package/rules/common/cost-aware-model-routing.md +152 -0
- package/scripts/dashboard/data.js +353 -0
- package/scripts/dashboard/server.js +324 -0
- package/scripts/dashboard/static/index.html +645 -0
- package/scripts/evolve/analyze.js +303 -0
- package/scripts/evolve/proposals.js +162 -0
- package/scripts/evolve/stats.js +219 -0
- package/scripts/mcp/catalog.js +244 -0
- package/scripts/mcp/client.js +181 -0
- package/scripts/mcp/prompts.js +133 -0
- package/scripts/mcp/resources.js +94 -0
- package/scripts/mcp/server.js +153 -0
- package/scripts/mcp/tools.js +465 -0
- package/scripts/replay/bundle.js +191 -0
- package/scripts/replay/replay.js +107 -0
- package/scripts/router/classify.js +232 -0
- package/scripts/supply-chain/manifest.js +154 -0
- package/scripts/supply-chain/sbom.js +202 -0
- package/scripts/supply-chain/verify.js +102 -0
- package/scripts/swarm/build-plan.js +193 -0
- package/skills/cost-aware-model-routing/SKILL.md +153 -0
- package/skills/kodelyth-quickstart/SKILL.md +7 -0
- package/skills/observability-dashboard/SKILL.md +119 -0
- package/skills/self-evolving-memory/SKILL.md +175 -0
- package/skills/session-replay/SKILL.md +199 -0
- package/skills/supply-chain-verification/SKILL.md +201 -0
- package/skills/swarm-orchestrator/SKILL.md +177 -0
- package/social/card-install.svg +1 -1
- package/social/facebook-group/POST.md +121 -0
- package/social/facebook-group/fb-1-3am-debug.png +0 -0
- package/social/facebook-group/fb-1-3am-debug.svg +97 -0
- package/social/facebook-group/fb-2-cpu-upgrade.png +0 -0
- package/social/facebook-group/fb-2-cpu-upgrade.svg +132 -0
- package/social/facebook-group/fb-3-before-after.png +0 -0
- package/social/facebook-group/fb-3-before-after.svg +94 -0
- package/social/facebook-v150.svg +5 -5
- package/social/github-social-preview.svg +119 -100
- package/social/readme-hero.svg +11 -11
- package/social/section-agents.svg +57 -0
- package/social/section-author.svg +54 -0
- package/social/section-dashboard.svg +59 -0
- package/social/section-devil.svg +54 -0
- package/social/section-hooks.svg +51 -0
- package/social/section-install.svg +42 -0
- package/social/section-learning.svg +52 -0
- package/social/section-mcp.svg +46 -0
- package/social/section-memory.svg +57 -0
- package/social/section-parallel.svg +72 -0
- package/social/section-routing.svg +51 -0
- package/social/x-card-agents-grid.svg +6 -6
- package/tests/dashboard/data.test.js +235 -0
- package/tests/dashboard/server.test.js +240 -0
- package/tests/evolve/analyze.test.js +169 -0
- package/tests/evolve/proposals.test.js +173 -0
- package/tests/evolve/stats.test.js +159 -0
- package/tests/mcp/catalog.test.js +98 -0
- package/tests/mcp/client.test.js +109 -0
- package/tests/mcp/resources-prompts.test.js +70 -0
- package/tests/mcp/tools.test.js +159 -0
- package/tests/replay/bundle.test.js +181 -0
- package/tests/replay/replay.test.js +131 -0
- package/tests/router/classify.test.js +164 -0
- package/tests/safety/patterns.test.js +94 -0
- package/tests/safety/prompt-injection-guard.test.js +111 -0
- package/tests/safety/token-budget.test.js +119 -0
- package/tests/supply-chain/manifest.test.js +147 -0
- package/tests/supply-chain/sbom.test.js +170 -0
- package/tests/supply-chain/verify.test.js +146 -0
- package/tests/swarm/build-plan.test.js +188 -0
- package/wiki/Agent-Reference.md +58 -7
- package/wiki/FAQ.md +204 -7
- package/wiki/Home.md +104 -28
- package/wiki/Hook-Reference.md +1 -1
- package/wiki/Installation-Guide.md +109 -6
- package/wiki/Platform-Support.md +239 -25
- package/wiki/Skill-Reference.md +101 -6
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Kodelyth ECC — Session Bundle (Phase 2.8)
|
|
3
|
+
//
|
|
4
|
+
// Pure read/write of portable session bundles. A bundle is a single JSON file
|
|
5
|
+
// that captures everything in a coordination directory:
|
|
6
|
+
//
|
|
7
|
+
// .orchestration/<session>/ ← input
|
|
8
|
+
// <worker-slug>/
|
|
9
|
+
// task.md
|
|
10
|
+
// handoff.md
|
|
11
|
+
// status.md
|
|
12
|
+
//
|
|
13
|
+
// Bundle format (v1):
|
|
14
|
+
// {
|
|
15
|
+
// "schema": "kodelyth.session-bundle/v1",
|
|
16
|
+
// "session": "swarm-2026-05-10-4a",
|
|
17
|
+
// "exported_at": "2026-05-10T17:30:00Z",
|
|
18
|
+
// "exported_by": "kodelyth-ecc@1.7.0",
|
|
19
|
+
// "meta": { ← optional, for replay variations
|
|
20
|
+
// "task": "audit oauth flow",
|
|
21
|
+
// "agents": ["security-reviewer", "code-reviewer", ...],
|
|
22
|
+
// "harness": "claude",
|
|
23
|
+
// "base_ref": "HEAD",
|
|
24
|
+
// "repo_root": "/path/to/repo"
|
|
25
|
+
// },
|
|
26
|
+
// "workers": [
|
|
27
|
+
// { "slug": "security-reviewer", "task": "...", "handoff": "...", "status": "..." }
|
|
28
|
+
// ]
|
|
29
|
+
// }
|
|
30
|
+
//
|
|
31
|
+
// Pure functions, zero deps. Tested without spawning anything.
|
|
32
|
+
// =============================================================================
|
|
33
|
+
|
|
34
|
+
'use strict';
|
|
35
|
+
|
|
36
|
+
const fs = require('fs');
|
|
37
|
+
const path = require('path');
|
|
38
|
+
|
|
39
|
+
const BUNDLE_SCHEMA = 'kodelyth.session-bundle/v1';
|
|
40
|
+
const BUNDLE_VERSION = '1.7.0';
|
|
41
|
+
|
|
42
|
+
// ── Read a coordination dir into a bundle ────────────────────────────────────
|
|
43
|
+
function exportBundle({ sessionDir, sessionName, meta = {} }) {
|
|
44
|
+
if (!sessionDir || typeof sessionDir !== 'string') {
|
|
45
|
+
throw new Error('exportBundle: sessionDir is required');
|
|
46
|
+
}
|
|
47
|
+
if (!fs.existsSync(sessionDir)) {
|
|
48
|
+
throw new Error(`exportBundle: session directory not found: ${sessionDir}`);
|
|
49
|
+
}
|
|
50
|
+
const session = sessionName || path.basename(sessionDir);
|
|
51
|
+
|
|
52
|
+
const entries = fs.readdirSync(sessionDir, { withFileTypes: true });
|
|
53
|
+
const workers = [];
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
if (!entry.isDirectory()) continue;
|
|
56
|
+
const slug = entry.name;
|
|
57
|
+
const dir = path.join(sessionDir, slug);
|
|
58
|
+
workers.push({
|
|
59
|
+
slug,
|
|
60
|
+
task: safeRead(path.join(dir, 'task.md')),
|
|
61
|
+
handoff: safeRead(path.join(dir, 'handoff.md')),
|
|
62
|
+
status: safeRead(path.join(dir, 'status.md')),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
workers.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
schema: BUNDLE_SCHEMA,
|
|
70
|
+
session,
|
|
71
|
+
exported_at: new Date().toISOString(),
|
|
72
|
+
exported_by: `kodelyth-ecc@${BUNDLE_VERSION}`,
|
|
73
|
+
meta: { ...meta },
|
|
74
|
+
workers,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Write a bundle JSON to disk ──────────────────────────────────────────────
|
|
79
|
+
function writeBundle(bundle, outPath) {
|
|
80
|
+
if (!bundle || bundle.schema !== BUNDLE_SCHEMA) {
|
|
81
|
+
throw new Error(`writeBundle: invalid schema (${bundle && bundle.schema})`);
|
|
82
|
+
}
|
|
83
|
+
fs.mkdirSync(path.dirname(path.resolve(outPath)), { recursive: true });
|
|
84
|
+
fs.writeFileSync(outPath, JSON.stringify(bundle, null, 2) + '\n', 'utf8');
|
|
85
|
+
return outPath;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Read + validate a bundle file ────────────────────────────────────────────
|
|
89
|
+
function readBundle(bundlePath) {
|
|
90
|
+
if (!fs.existsSync(bundlePath)) {
|
|
91
|
+
throw new Error(`readBundle: file not found: ${bundlePath}`);
|
|
92
|
+
}
|
|
93
|
+
const raw = JSON.parse(fs.readFileSync(bundlePath, 'utf8'));
|
|
94
|
+
return validateBundle(raw);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function validateBundle(raw) {
|
|
98
|
+
if (!raw || typeof raw !== 'object') throw new Error('bundle: not an object');
|
|
99
|
+
if (raw.schema !== BUNDLE_SCHEMA) {
|
|
100
|
+
throw new Error(`bundle: unsupported schema "${raw.schema}" (expected "${BUNDLE_SCHEMA}")`);
|
|
101
|
+
}
|
|
102
|
+
if (typeof raw.session !== 'string' || !raw.session) {
|
|
103
|
+
throw new Error('bundle: missing "session"');
|
|
104
|
+
}
|
|
105
|
+
if (!Array.isArray(raw.workers) || raw.workers.length === 0) {
|
|
106
|
+
throw new Error('bundle: "workers" must be a non-empty array');
|
|
107
|
+
}
|
|
108
|
+
for (const w of raw.workers) {
|
|
109
|
+
if (!w || typeof w.slug !== 'string') throw new Error('bundle: each worker needs a string "slug"');
|
|
110
|
+
}
|
|
111
|
+
return raw;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Restore a bundle into a coordination dir ─────────────────────────────────
|
|
115
|
+
function importBundle(bundle, { targetDir, overwrite = false } = {}) {
|
|
116
|
+
validateBundle(bundle);
|
|
117
|
+
if (!targetDir) throw new Error('importBundle: targetDir is required');
|
|
118
|
+
const dir = path.resolve(targetDir);
|
|
119
|
+
if (fs.existsSync(dir)) {
|
|
120
|
+
if (!overwrite) throw new Error(`importBundle: targetDir already exists: ${dir} (pass overwrite=true to replace)`);
|
|
121
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
122
|
+
}
|
|
123
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
124
|
+
for (const w of bundle.workers) {
|
|
125
|
+
const wdir = path.join(dir, w.slug);
|
|
126
|
+
fs.mkdirSync(wdir, { recursive: true });
|
|
127
|
+
if (typeof w.task === 'string') fs.writeFileSync(path.join(wdir, 'task.md'), w.task, 'utf8');
|
|
128
|
+
if (typeof w.handoff === 'string') fs.writeFileSync(path.join(wdir, 'handoff.md'), w.handoff, 'utf8');
|
|
129
|
+
if (typeof w.status === 'string') fs.writeFileSync(path.join(wdir, 'status.md'), w.status, 'utf8');
|
|
130
|
+
}
|
|
131
|
+
return { targetDir: dir, workers: bundle.workers.map(w => w.slug) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Diff: compare two bundles' handoffs (for A/B replay analysis) ────────────
|
|
135
|
+
function diffBundles(a, b) {
|
|
136
|
+
validateBundle(a);
|
|
137
|
+
validateBundle(b);
|
|
138
|
+
|
|
139
|
+
const aBySlug = new Map(a.workers.map(w => [w.slug, w]));
|
|
140
|
+
const bBySlug = new Map(b.workers.map(w => [w.slug, w]));
|
|
141
|
+
const allSlugs = new Set([...aBySlug.keys(), ...bBySlug.keys()]);
|
|
142
|
+
|
|
143
|
+
const out = [];
|
|
144
|
+
for (const slug of [...allSlugs].sort()) {
|
|
145
|
+
const A = aBySlug.get(slug);
|
|
146
|
+
const B = bBySlug.get(slug);
|
|
147
|
+
out.push({
|
|
148
|
+
slug,
|
|
149
|
+
in_a: !!A,
|
|
150
|
+
in_b: !!B,
|
|
151
|
+
task_changed: !!A && !!B && (A.task || '') !== (B.task || ''),
|
|
152
|
+
handoff_changed: !!A && !!B && (A.handoff || '') !== (B.handoff || ''),
|
|
153
|
+
status_changed: !!A && !!B && (A.status || '') !== (B.status || ''),
|
|
154
|
+
a_handoff_len: A ? (A.handoff || '').length : 0,
|
|
155
|
+
b_handoff_len: B ? (B.handoff || '').length : 0,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Helper ───────────────────────────────────────────────────────────────────
|
|
162
|
+
function safeRead(p) {
|
|
163
|
+
try { return fs.readFileSync(p, 'utf8'); }
|
|
164
|
+
catch { return ''; }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Compute the next replay session name ─────────────────────────────────────
|
|
168
|
+
function nextReplayName(originalSession, takenSet = new Set()) {
|
|
169
|
+
if (typeof originalSession !== 'string' || !originalSession) {
|
|
170
|
+
throw new Error('nextReplayName: originalSession is required');
|
|
171
|
+
}
|
|
172
|
+
// Strip any existing -replay-N suffix to get the canonical base.
|
|
173
|
+
const base = originalSession.replace(/-replay-\d+$/, '');
|
|
174
|
+
for (let n = 1; n < 1000; n++) {
|
|
175
|
+
const candidate = `${base}-replay-${n}`;
|
|
176
|
+
if (!takenSet.has(candidate)) return candidate;
|
|
177
|
+
}
|
|
178
|
+
throw new Error('nextReplayName: exhausted replay numbers');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = {
|
|
182
|
+
BUNDLE_SCHEMA,
|
|
183
|
+
BUNDLE_VERSION,
|
|
184
|
+
exportBundle,
|
|
185
|
+
writeBundle,
|
|
186
|
+
readBundle,
|
|
187
|
+
validateBundle,
|
|
188
|
+
importBundle,
|
|
189
|
+
diffBundles,
|
|
190
|
+
nextReplayName,
|
|
191
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Kodelyth ECC — Session Replay Engine (Phase 2.8)
|
|
3
|
+
//
|
|
4
|
+
// Takes a bundle (or a live coordination dir) and reconstructs a swarm plan-
|
|
5
|
+
// config that re-runs the same task. Supports replay variations:
|
|
6
|
+
//
|
|
7
|
+
// - Different harness (--harness codex) — model A/B test
|
|
8
|
+
// - Different agents (--agents new1,new2) — agent A/B test
|
|
9
|
+
// - Different baseRef (--base-ref main) — re-test against new code
|
|
10
|
+
// - Different model via cost router env vars — model A/B test
|
|
11
|
+
//
|
|
12
|
+
// Pure planning. The actual execution is delegated to the existing
|
|
13
|
+
// orchestrator (scripts/lib/tmux-worktree-orchestrator.js).
|
|
14
|
+
// =============================================================================
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const path = require('path');
|
|
19
|
+
|
|
20
|
+
const { buildSwarmPlan } = require('../swarm/build-plan.js');
|
|
21
|
+
const { nextReplayName } = require('./bundle.js');
|
|
22
|
+
|
|
23
|
+
// ── Extract a replay-able task from a bundle ─────────────────────────────────
|
|
24
|
+
function extractTaskFromBundle(bundle) {
|
|
25
|
+
// Prefer explicit meta.task (set when exporting from a swarm).
|
|
26
|
+
if (bundle.meta && typeof bundle.meta.task === 'string' && bundle.meta.task.trim()) {
|
|
27
|
+
return bundle.meta.task.trim();
|
|
28
|
+
}
|
|
29
|
+
// Fallback: read the FIRST worker's task.md and strip the agent-shaped header
|
|
30
|
+
// we added in scripts/swarm/build-plan.js (shapeTaskForAgent).
|
|
31
|
+
const first = bundle.workers && bundle.workers[0];
|
|
32
|
+
if (first && typeof first.task === 'string') {
|
|
33
|
+
return extractSharedTaskFromShapedPrompt(first.task);
|
|
34
|
+
}
|
|
35
|
+
throw new Error('replay: cannot extract task — bundle has no meta.task and no worker tasks');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function extractSharedTaskFromShapedPrompt(prompt) {
|
|
39
|
+
// The shaped format is:
|
|
40
|
+
// # <agent> — swarm task
|
|
41
|
+
// ...
|
|
42
|
+
// ## Shared Task
|
|
43
|
+
//
|
|
44
|
+
// <THE TASK>
|
|
45
|
+
//
|
|
46
|
+
// ## Required handoff sections
|
|
47
|
+
// ...
|
|
48
|
+
const m = prompt.match(/##\s*Shared Task\s*\n([\s\S]*?)\n##\s*Required handoff sections/);
|
|
49
|
+
if (m) return m[1].trim();
|
|
50
|
+
// Tmux orchestrator's own task.md has "## Objective\n<task>\n## Completion".
|
|
51
|
+
const m2 = prompt.match(/##\s*Objective\s*\n([\s\S]*?)\n##\s*Completion/);
|
|
52
|
+
if (m2) return m2[1].trim();
|
|
53
|
+
// Last-resort: return the whole prompt.
|
|
54
|
+
return prompt.trim();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Build a replay plan-config ───────────────────────────────────────────────
|
|
58
|
+
function buildReplayPlanConfig(bundle, opts = {}) {
|
|
59
|
+
const {
|
|
60
|
+
repoRoot = process.cwd(),
|
|
61
|
+
coordinationRoot,
|
|
62
|
+
worktreeRoot,
|
|
63
|
+
baseRef,
|
|
64
|
+
harness,
|
|
65
|
+
agents, // array | null — overrides bundle's agent list
|
|
66
|
+
sessionName, // explicit override; otherwise auto next-replay-N
|
|
67
|
+
takenSessions = new Set(),
|
|
68
|
+
replaceExisting = false,
|
|
69
|
+
seedPaths = [],
|
|
70
|
+
launcherCmd,
|
|
71
|
+
} = opts;
|
|
72
|
+
|
|
73
|
+
const task = extractTaskFromBundle(bundle);
|
|
74
|
+
|
|
75
|
+
// Resolve which agents to run.
|
|
76
|
+
let agentList;
|
|
77
|
+
if (Array.isArray(agents) && agents.length > 0) {
|
|
78
|
+
agentList = agents.slice();
|
|
79
|
+
} else if (bundle.meta && Array.isArray(bundle.meta.agents) && bundle.meta.agents.length > 0) {
|
|
80
|
+
agentList = bundle.meta.agents.slice();
|
|
81
|
+
} else {
|
|
82
|
+
agentList = bundle.workers.map(w => w.slug);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Resolve session name.
|
|
86
|
+
const session = sessionName || nextReplayName(bundle.session, takenSessions);
|
|
87
|
+
|
|
88
|
+
return buildSwarmPlan({
|
|
89
|
+
task,
|
|
90
|
+
agents: agentList,
|
|
91
|
+
harness: harness || (bundle.meta && bundle.meta.harness) || 'claude',
|
|
92
|
+
launcherCmd,
|
|
93
|
+
sessionName: session,
|
|
94
|
+
repoRoot,
|
|
95
|
+
worktreeRoot,
|
|
96
|
+
coordinationRoot,
|
|
97
|
+
baseRef: baseRef || (bundle.meta && bundle.meta.base_ref) || 'HEAD',
|
|
98
|
+
seedPaths,
|
|
99
|
+
replaceExisting,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = {
|
|
104
|
+
extractTaskFromBundle,
|
|
105
|
+
extractSharedTaskFromShapedPrompt,
|
|
106
|
+
buildReplayPlanConfig,
|
|
107
|
+
};
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Kodelyth ECC — Cost-Aware Model Router (Classifier + Config Loader)
|
|
3
|
+
//
|
|
4
|
+
// Pure-function task classifier. Given a task description and optional
|
|
5
|
+
// signals (file count, agent in scope, security flags, etc.), returns a
|
|
6
|
+
// recommended tier ("trivial" | "standard" | "hard") plus the top reasons.
|
|
7
|
+
//
|
|
8
|
+
// Used by:
|
|
9
|
+
// - npx kodelyth-ecc route <task...> ad-hoc CLI
|
|
10
|
+
// - skills/cost-aware-model-routing explicit invocation
|
|
11
|
+
// - any future hook that wants a hint
|
|
12
|
+
//
|
|
13
|
+
// Pure JS, zero deps, deterministic.
|
|
14
|
+
// =============================================================================
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const os = require('os');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
|
|
22
|
+
// ── Defaults (provider-agnostic) ─────────────────────────────────────────────
|
|
23
|
+
const DEFAULT_MODELS = {
|
|
24
|
+
trivial: 'claude-haiku-4-5-20251001',
|
|
25
|
+
standard: 'claude-sonnet-4-6',
|
|
26
|
+
hard: 'claude-opus-4-1',
|
|
27
|
+
default: 'standard',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Agents that should NEVER downgrade — security / incident / adversarial work.
|
|
31
|
+
const HARD_TIER_AGENTS = new Set([
|
|
32
|
+
'security-reviewer', 'incident-commander', 'debug-detective',
|
|
33
|
+
'prompt-injection-hunter', 'supply-chain-auditor', 'secret-hunter',
|
|
34
|
+
'backdoor-hunter', 'chaos-engineer', 'jailbreak-tester',
|
|
35
|
+
'code-stealer-detector', 'license-violation-finder',
|
|
36
|
+
'code-architect', 'architect',
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
// ── Signal catalogues ────────────────────────────────────────────────────────
|
|
40
|
+
const TRIVIAL_SIGNALS = [
|
|
41
|
+
{ id: 'rename', regex: /\b(rename|renaming)\b/i },
|
|
42
|
+
{ id: 'format', regex: /\b(format|formatting|prettier|biome|reformat)\b/i },
|
|
43
|
+
{ id: 'typo', regex: /\b(typo|misspell|spelling)\b/i },
|
|
44
|
+
{ id: 'doc-comment', regex: /\b(jsdoc|tsdoc|docstring|add comments?|add docs?)\b/i },
|
|
45
|
+
{ id: 'capitalize', regex: /\b(capitali[sz]e|lowercase|uppercase|kebab-case|snake_case|camelCase)\b/i },
|
|
46
|
+
{ id: 'list-files', regex: /\b(list|count|enumerate)\s+(files?|imports?|exports?|functions?)\b/i },
|
|
47
|
+
{ id: 'summarize', regex: /\b(summari[sz]e|tl;?dr|short summary)\b/i },
|
|
48
|
+
{ id: 'version-bump',regex: /\b(version\s+bump|bump\s+(version|deps?))\b/i },
|
|
49
|
+
{ id: 'simple-regex',regex: /\b(simple\s+regex|one[\s-]liner|small\s+regex)\b/i },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const HARD_SIGNALS = [
|
|
53
|
+
{ id: 'production', regex: /\b(production|prod|live|outage|incident|down|leaking|on[\s-]?call|p[01]|sev[12])\b/i, weight: 3 },
|
|
54
|
+
{ id: 'security', regex: /\b(security|vuln(?:erability)?|cve|exploit|injection|auth(?:n|z)?\s+(?:bypass|flaw)|secrets?\s+(?:leak|exposure))\b/i, weight: 3 },
|
|
55
|
+
{ id: 'architecture',regex: /\b(architect(?:ure|ural)?\s+(?:decision|design|review|trade[\s-]?off)|design\s+review|adr|RFC)\b/i, weight: 2 },
|
|
56
|
+
{ id: 'multi-file', regex: /\b(across\s+(?:the\s+)?(?:codebase|repo|project)|every(?:where)?|all\s+(?:files|modules|services))\b/i, weight: 2 },
|
|
57
|
+
{ id: 'concurrency', regex: /\b(race\s+condition|deadlock|concurrency|thread[\s-]safe|atomic|mutex|lock)\b/i, weight: 2 },
|
|
58
|
+
{ id: 'urgent', regex: /\b(urgent|asap|right\s+now|emergency|critical(?:al)?ly|fire|burning)\b/i, weight: 2 },
|
|
59
|
+
{ id: 'devil-mode', regex: /\b(\/devil-mode|devil[\s-]?mode|red[\s-]?team|adversarial)\b/i, weight: 3 },
|
|
60
|
+
{ id: 'incident', regex: /\bincident[\s-]?(commander|response|triage|postmortem|post[\s-]?mortem)\b/i, weight: 3 },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const STANDARD_SIGNALS = [
|
|
64
|
+
{ id: 'review-pr', regex: /\b(review\s+(?:this\s+)?pr|code\s+review|pr\s+review)\b/i },
|
|
65
|
+
{ id: 'write-test', regex: /\b(write\s+(?:a\s+)?(?:unit\s+)?tests?|add\s+tests?)\b/i },
|
|
66
|
+
{ id: 'fix-bug', regex: /\b(fix\s+(?:this\s+)?bug|debug\s+this|why\s+is\s+this\s+failing)\b/i },
|
|
67
|
+
{ id: 'refactor', regex: /\brefactor(?:ing)?\b/i },
|
|
68
|
+
{ id: 'optimize', regex: /\b(optimi[sz]e|speed\s+up|improve\s+performance)\b/i },
|
|
69
|
+
{ id: 'doc-section', regex: /\b(write\s+docs?|document(?:ation)?\s+for)\b/i },
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
// ── Token utilities ──────────────────────────────────────────────────────────
|
|
73
|
+
function chars(s) { return typeof s === 'string' ? s.length : 0; }
|
|
74
|
+
|
|
75
|
+
// ── Main classifier ──────────────────────────────────────────────────────────
|
|
76
|
+
function classify(task, opts = {}) {
|
|
77
|
+
if (!task || typeof task !== 'string') {
|
|
78
|
+
return { tier: 'standard', reasons: ['no task text — defaulting to standard'], score: { trivial: 0, standard: 0, hard: 0 } };
|
|
79
|
+
}
|
|
80
|
+
const t = task.trim();
|
|
81
|
+
const len = chars(t);
|
|
82
|
+
|
|
83
|
+
const fileCount = Number(opts.file_count) || 0;
|
|
84
|
+
const activeAgent = String(opts.active_agent || '').trim();
|
|
85
|
+
const sessionTokens = Number(opts.session_tokens) || 0;
|
|
86
|
+
const budgetTokens = Number(opts.budget_tokens) || 0;
|
|
87
|
+
const explicitTier = opts.explicit_tier; // if user set --model X, we respect it.
|
|
88
|
+
|
|
89
|
+
const score = { trivial: 0, standard: 0, hard: 0 };
|
|
90
|
+
const reasons = [];
|
|
91
|
+
|
|
92
|
+
// ── HARD: agent in scope ────────────────────────────────────────────────────
|
|
93
|
+
if (activeAgent && HARD_TIER_AGENTS.has(activeAgent)) {
|
|
94
|
+
score.hard += 4;
|
|
95
|
+
reasons.push(`active agent "${activeAgent}" never downgrades`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── HARD signals ────────────────────────────────────────────────────────────
|
|
99
|
+
for (const s of HARD_SIGNALS) {
|
|
100
|
+
if (s.regex.test(t)) {
|
|
101
|
+
score.hard += s.weight;
|
|
102
|
+
reasons.push(`hard:${s.id}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── STANDARD signals ────────────────────────────────────────────────────────
|
|
107
|
+
for (const s of STANDARD_SIGNALS) {
|
|
108
|
+
if (s.regex.test(t)) {
|
|
109
|
+
score.standard += 1;
|
|
110
|
+
reasons.push(`standard:${s.id}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── TRIVIAL signals ─────────────────────────────────────────────────────────
|
|
115
|
+
for (const s of TRIVIAL_SIGNALS) {
|
|
116
|
+
if (s.regex.test(t)) {
|
|
117
|
+
score.trivial += 1;
|
|
118
|
+
reasons.push(`trivial:${s.id}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── File-count signals ──────────────────────────────────────────────────────
|
|
123
|
+
if (fileCount >= 4) {
|
|
124
|
+
score.hard += 2;
|
|
125
|
+
reasons.push(`file_count=${fileCount} ≥ 4 (multi-file refactor)`);
|
|
126
|
+
} else if (fileCount === 0 && len < 80 && score.hard === 0) {
|
|
127
|
+
score.trivial += 1;
|
|
128
|
+
reasons.push(`short task (${len} chars), no files in scope`);
|
|
129
|
+
} else if (fileCount >= 1 && fileCount <= 3) {
|
|
130
|
+
score.standard += 1;
|
|
131
|
+
reasons.push(`file_count=${fileCount} (focused scope)`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Length signals ──────────────────────────────────────────────────────────
|
|
135
|
+
if (len > 800 && score.trivial === 0) {
|
|
136
|
+
score.standard += 1;
|
|
137
|
+
reasons.push(`long task description (${len} chars)`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── Budget pressure: bias one tier downward when ambiguous ──────────────────
|
|
141
|
+
if (budgetTokens > 0 && sessionTokens / budgetTokens > 0.7 && score.hard === 0) {
|
|
142
|
+
if (score.standard > 0) {
|
|
143
|
+
score.trivial += 1;
|
|
144
|
+
reasons.push('budget > 70% — biasing downward when standard/trivial tied');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Tier resolution ─────────────────────────────────────────────────────────
|
|
149
|
+
// Hard rules first.
|
|
150
|
+
if (score.hard >= 2) {
|
|
151
|
+
return { tier: 'hard', reasons, score, explicit_tier: explicitTier || null };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Trivial wins ONLY if it dominates and no hard signal at all.
|
|
155
|
+
if (score.hard === 0 && score.trivial > 0 && score.trivial >= score.standard) {
|
|
156
|
+
return { tier: 'trivial', reasons, score, explicit_tier: explicitTier || null };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Anything with a hard signal but not enough weight → standard (defensive).
|
|
160
|
+
if (score.hard > 0) {
|
|
161
|
+
return { tier: 'standard', reasons: [...reasons, 'hard signal present but weight < 2 → defensive standard'], score, explicit_tier: explicitTier || null };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { tier: 'standard', reasons, score, explicit_tier: explicitTier || null };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Config loader (env vars + .kodelyth/router.json) ─────────────────────────
|
|
168
|
+
function loadConfig({ projectRoot = process.cwd() } = {}) {
|
|
169
|
+
const cfg = { ...DEFAULT_MODELS, notes: '' };
|
|
170
|
+
|
|
171
|
+
// 1. Project-level override.
|
|
172
|
+
try {
|
|
173
|
+
const file = path.join(projectRoot, '.kodelyth', 'router.json');
|
|
174
|
+
if (fs.existsSync(file)) {
|
|
175
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
176
|
+
for (const k of ['trivial', 'standard', 'hard', 'default', 'notes']) {
|
|
177
|
+
if (typeof raw[k] === 'string') cfg[k] = raw[k];
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
} catch { /* ignore — fall back to defaults */ }
|
|
181
|
+
|
|
182
|
+
// 2. Env-var override (highest precedence).
|
|
183
|
+
if (process.env.KODELYTH_ROUTER_TRIVIAL) cfg.trivial = process.env.KODELYTH_ROUTER_TRIVIAL;
|
|
184
|
+
if (process.env.KODELYTH_ROUTER_STANDARD) cfg.standard = process.env.KODELYTH_ROUTER_STANDARD;
|
|
185
|
+
if (process.env.KODELYTH_ROUTER_HARD) cfg.hard = process.env.KODELYTH_ROUTER_HARD;
|
|
186
|
+
if (process.env.KODELYTH_ROUTER_DEFAULT) cfg.default = process.env.KODELYTH_ROUTER_DEFAULT;
|
|
187
|
+
|
|
188
|
+
cfg.disabled = String(process.env.KODELYTH_ROUTER || '').toLowerCase() === 'off';
|
|
189
|
+
return cfg;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Recommend (combines classify + config) ───────────────────────────────────
|
|
193
|
+
function recommend(task, opts = {}) {
|
|
194
|
+
const cfg = loadConfig({ projectRoot: opts.project_root });
|
|
195
|
+
if (cfg.disabled) {
|
|
196
|
+
return {
|
|
197
|
+
disabled: true,
|
|
198
|
+
tier: 'unknown',
|
|
199
|
+
model: opts.current_model || null,
|
|
200
|
+
reasons: ['KODELYTH_ROUTER=off — router disabled'],
|
|
201
|
+
config: cfg,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
const cls = classify(task, opts);
|
|
205
|
+
const tier = cls.tier;
|
|
206
|
+
const recommendedModel = cfg[tier] || cfg.standard;
|
|
207
|
+
const currentModel = opts.current_model || null;
|
|
208
|
+
const mismatched = !!currentModel && currentModel !== recommendedModel;
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
disabled: false,
|
|
212
|
+
tier,
|
|
213
|
+
recommended_model: recommendedModel,
|
|
214
|
+
current_model: currentModel,
|
|
215
|
+
mismatched,
|
|
216
|
+
reasons: cls.reasons,
|
|
217
|
+
score: cls.score,
|
|
218
|
+
notes: cfg.notes || '',
|
|
219
|
+
config: cfg,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
module.exports = {
|
|
224
|
+
DEFAULT_MODELS,
|
|
225
|
+
HARD_TIER_AGENTS,
|
|
226
|
+
TRIVIAL_SIGNALS,
|
|
227
|
+
STANDARD_SIGNALS,
|
|
228
|
+
HARD_SIGNALS,
|
|
229
|
+
classify,
|
|
230
|
+
loadConfig,
|
|
231
|
+
recommend,
|
|
232
|
+
};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// scripts/supply-chain/manifest.js
|
|
2
|
+
//
|
|
3
|
+
// Content manifest generator for kodelyth-ecc.
|
|
4
|
+
//
|
|
5
|
+
// Walks every shipped asset directory, computes sha256 of each file, and
|
|
6
|
+
// produces a stable, deterministic JSON manifest.
|
|
7
|
+
//
|
|
8
|
+
// The manifest is the "ground truth" for `kodelyth-ecc verify`: an installed
|
|
9
|
+
// copy is compared file-by-file against this manifest.
|
|
10
|
+
//
|
|
11
|
+
// Pure functions — no network, no external deps.
|
|
12
|
+
//
|
|
13
|
+
// Exports:
|
|
14
|
+
// - MANIFEST_SCHEMA
|
|
15
|
+
// - SHIPPED_DIRS (default list of asset directories)
|
|
16
|
+
// - generateManifest({ rootDir, dirs?, timestamp? })
|
|
17
|
+
// - hashFile(absPath)
|
|
18
|
+
// - walkFiles(rootDir, dirs) (iterator-friendly array, sorted)
|
|
19
|
+
//
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const crypto = require('crypto');
|
|
25
|
+
|
|
26
|
+
const MANIFEST_SCHEMA = 'kodelyth.content-manifest/v1';
|
|
27
|
+
const MANIFEST_VERSION = 1;
|
|
28
|
+
|
|
29
|
+
// Directories whose contents are considered part of the shipped artifact.
|
|
30
|
+
// Order doesn't matter — output is sorted by relative path.
|
|
31
|
+
const SHIPPED_DIRS = [
|
|
32
|
+
'agents',
|
|
33
|
+
'skills',
|
|
34
|
+
'commands',
|
|
35
|
+
'rules',
|
|
36
|
+
'hooks',
|
|
37
|
+
'scripts',
|
|
38
|
+
'bin',
|
|
39
|
+
'parallel-commands',
|
|
40
|
+
'bundles',
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
// Files that should be included individually from the repo root.
|
|
44
|
+
const SHIPPED_ROOT_FILES = [
|
|
45
|
+
'package.json',
|
|
46
|
+
'README.md',
|
|
47
|
+
'CHANGELOG.md',
|
|
48
|
+
'VERSION',
|
|
49
|
+
'install.sh',
|
|
50
|
+
'install.ps1',
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
// Patterns to skip even within shipped dirs.
|
|
54
|
+
const SKIP_PATTERNS = [
|
|
55
|
+
/\/\.DS_Store$/,
|
|
56
|
+
/\/node_modules\//,
|
|
57
|
+
/\/\.git\//,
|
|
58
|
+
/\/__pycache__\//,
|
|
59
|
+
/\.pyc$/,
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
function isSkipped(absPath) {
|
|
63
|
+
return SKIP_PATTERNS.some(p => p.test(absPath));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function hashFile(absPath) {
|
|
67
|
+
const buf = fs.readFileSync(absPath);
|
|
68
|
+
return crypto.createHash('sha256').update(buf).digest('hex');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function* walkRecursive(absDir) {
|
|
72
|
+
let entries;
|
|
73
|
+
try { entries = fs.readdirSync(absDir, { withFileTypes: true }); }
|
|
74
|
+
catch { return; }
|
|
75
|
+
// Sort for stability.
|
|
76
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
77
|
+
for (const e of entries) {
|
|
78
|
+
const abs = path.join(absDir, e.name);
|
|
79
|
+
if (isSkipped(abs)) continue;
|
|
80
|
+
if (e.isDirectory()) yield* walkRecursive(abs);
|
|
81
|
+
else if (e.isFile()) yield abs;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function walkFiles(rootDir, dirs = SHIPPED_DIRS, rootFiles = SHIPPED_ROOT_FILES) {
|
|
86
|
+
if (!rootDir) throw new Error('walkFiles: rootDir is required');
|
|
87
|
+
const out = [];
|
|
88
|
+
|
|
89
|
+
for (const file of rootFiles) {
|
|
90
|
+
const abs = path.join(rootDir, file);
|
|
91
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isFile() && !isSkipped(abs)) {
|
|
92
|
+
out.push(abs);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const dir of dirs) {
|
|
97
|
+
const abs = path.join(rootDir, dir);
|
|
98
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) continue;
|
|
99
|
+
for (const f of walkRecursive(abs)) out.push(f);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Final sort by relative path so manifest output is deterministic.
|
|
103
|
+
return out.sort((a, b) => a.localeCompare(b));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function generateManifest({ rootDir, dirs, rootFiles, timestamp } = {}) {
|
|
107
|
+
if (!rootDir) throw new Error('generateManifest: rootDir is required');
|
|
108
|
+
const files = walkFiles(rootDir, dirs, rootFiles);
|
|
109
|
+
|
|
110
|
+
const pkgPath = path.join(rootDir, 'package.json');
|
|
111
|
+
let pkgName = 'kodelyth-ecc';
|
|
112
|
+
let pkgVersion = '0.0.0';
|
|
113
|
+
try {
|
|
114
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
115
|
+
pkgName = pkg.name || pkgName;
|
|
116
|
+
pkgVersion = pkg.version || pkgVersion;
|
|
117
|
+
} catch { /* leave defaults */ }
|
|
118
|
+
|
|
119
|
+
const entries = files.map(abs => {
|
|
120
|
+
const rel = path.relative(rootDir, abs).split(path.sep).join('/');
|
|
121
|
+
const stat = fs.statSync(abs);
|
|
122
|
+
return {
|
|
123
|
+
path: rel,
|
|
124
|
+
size: stat.size,
|
|
125
|
+
sha256: hashFile(abs),
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Compute a manifest-level digest = sha256 over the deterministic JSON of entries.
|
|
130
|
+
const entriesJson = JSON.stringify(entries);
|
|
131
|
+
const manifestDigest = crypto.createHash('sha256').update(entriesJson).digest('hex');
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
schema: MANIFEST_SCHEMA,
|
|
135
|
+
version: MANIFEST_VERSION,
|
|
136
|
+
package: pkgName,
|
|
137
|
+
pkg_version: pkgVersion,
|
|
138
|
+
generated_at: timestamp || new Date().toISOString(),
|
|
139
|
+
file_count: entries.length,
|
|
140
|
+
digest: manifestDigest,
|
|
141
|
+
files: entries,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = {
|
|
146
|
+
MANIFEST_SCHEMA,
|
|
147
|
+
MANIFEST_VERSION,
|
|
148
|
+
SHIPPED_DIRS,
|
|
149
|
+
SHIPPED_ROOT_FILES,
|
|
150
|
+
SKIP_PATTERNS,
|
|
151
|
+
generateManifest,
|
|
152
|
+
hashFile,
|
|
153
|
+
walkFiles,
|
|
154
|
+
};
|