kodelyth-ecc 2.24.11 → 2.29.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/CHANGELOG.md +208 -0
- package/CLAUDE.md +2 -2
- package/README.md +7 -4
- package/agents/super-agent.md +102 -0
- package/bin/kodelyth-ecc.js +57 -2
- package/commands/super-agent.md +47 -0
- package/hooks/memory/capture-stop.js +1 -1
- package/install.ps1 +12 -1
- package/install.sh +14 -1
- package/package.json +5 -2
- package/rules/common/agent-intent-routing.md +6 -1
- package/scripts/cli/menu.js +1 -1
- package/scripts/codebase/index.js +59 -2
- package/scripts/hooks/post-bash-command-log.js +4 -11
- package/scripts/hooks/session-start.js +25 -0
- package/scripts/lib/redact.js +58 -0
- package/scripts/lib/state-store/index.js +36 -1
- package/scripts/lib/state-store/schema.js +18 -1
- package/scripts/mcp/register-self.js +120 -3
- package/scripts/memory/cli.js +35 -0
- package/scripts/memory/embedding-providers/local-transformers.js +44 -0
- package/scripts/memory/embeddings.js +98 -0
- package/scripts/memory/instinct-inject.js +67 -0
- package/scripts/memory/pending.js +125 -0
- package/scripts/memory/project-id.js +93 -0
- package/scripts/memory/store.js +123 -13
- package/scripts/rtk/index.js +43 -1
- package/scripts/super-agent/cli.js +135 -0
- package/scripts/super-agent/dag.js +123 -0
- package/scripts/super-agent/integrate.js +91 -0
- package/scripts/super-agent/orchestrator.js +175 -0
- package/scripts/super-agent/state.js +136 -0
- package/scripts/terse/ledger.js +16 -5
- package/skills/terse-mode/SKILL.md +1 -1
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// scripts/super-agent/dag.js
|
|
2
|
+
//
|
|
3
|
+
// Pure dependency-graph model for a super-agent run's plan. No I/O. A step is
|
|
4
|
+
// { id, description, deps: [ids], exitCriteria?, status, ... }
|
|
5
|
+
// and this module answers the only questions the orchestrator needs: which steps
|
|
6
|
+
// are runnable right now, is the plan finished, and is it stuck. Keeping this
|
|
7
|
+
// pure is what makes the whole control plane testable without spawning an agent.
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const STATUS = Object.freeze({
|
|
11
|
+
PENDING: 'pending',
|
|
12
|
+
IN_PROGRESS: 'in_progress',
|
|
13
|
+
DONE: 'done',
|
|
14
|
+
FAILED: 'failed',
|
|
15
|
+
SKIPPED: 'skipped',
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// Normalize a raw plan into well-formed steps (stable string ids, deps array,
|
|
19
|
+
// a starting status). Ids are coerced to strings so numeric ids from a blueprint
|
|
20
|
+
// and string deps compare correctly.
|
|
21
|
+
function normalizeSteps(steps) {
|
|
22
|
+
return (Array.isArray(steps) ? steps : []).map((s) => ({
|
|
23
|
+
status: STATUS.PENDING,
|
|
24
|
+
...s,
|
|
25
|
+
id: String(s && s.id != null ? s.id : ''),
|
|
26
|
+
deps: Array.isArray(s && s.deps) ? s.deps.map(String) : [],
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Validate the plan is a sane DAG: unique non-empty ids, deps that exist, no
|
|
31
|
+
// self-dependency, and no cycles. Returns { ok, errors: [] }.
|
|
32
|
+
function validate(steps) {
|
|
33
|
+
const errors = [];
|
|
34
|
+
const ids = new Set();
|
|
35
|
+
for (const s of steps) {
|
|
36
|
+
if (!s.id) errors.push('a step has no id');
|
|
37
|
+
else if (ids.has(s.id)) errors.push(`duplicate step id: ${s.id}`);
|
|
38
|
+
else ids.add(s.id);
|
|
39
|
+
}
|
|
40
|
+
for (const s of steps) {
|
|
41
|
+
for (const d of s.deps) {
|
|
42
|
+
if (d === s.id) errors.push(`step ${s.id} depends on itself`);
|
|
43
|
+
else if (!ids.has(d)) errors.push(`step ${s.id} depends on unknown step ${d}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (errors.length === 0 && hasCycle(steps)) errors.push('the plan has a dependency cycle');
|
|
47
|
+
return { ok: errors.length === 0, errors };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Kahn's algorithm: if we cannot topologically order every step, there is a cycle.
|
|
51
|
+
function hasCycle(steps) {
|
|
52
|
+
const indeg = new Map(steps.map((s) => [s.id, 0]));
|
|
53
|
+
const byId = new Map(steps.map((s) => [s.id, s]));
|
|
54
|
+
for (const s of steps) {
|
|
55
|
+
for (const d of s.deps) {
|
|
56
|
+
if (byId.has(d)) indeg.set(s.id, indeg.get(s.id) + 1);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const queue = [...indeg.entries()].filter(([, n]) => n === 0).map(([id]) => id);
|
|
60
|
+
const dependents = new Map(steps.map((s) => [s.id, []]));
|
|
61
|
+
for (const s of steps) {
|
|
62
|
+
for (const d of s.deps) {
|
|
63
|
+
if (dependents.has(d)) dependents.get(d).push(s.id);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
let visited = 0;
|
|
67
|
+
while (queue.length) {
|
|
68
|
+
const id = queue.shift();
|
|
69
|
+
visited++;
|
|
70
|
+
for (const dep of dependents.get(id) || []) {
|
|
71
|
+
indeg.set(dep, indeg.get(dep) - 1);
|
|
72
|
+
if (indeg.get(dep) === 0) queue.push(dep);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return visited !== steps.length;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Steps that can run now: still pending, with every dependency DONE (or SKIPPED,
|
|
79
|
+
// which counts as satisfied — a skipped step did not fail).
|
|
80
|
+
function readySteps(steps) {
|
|
81
|
+
const satisfied = new Set(
|
|
82
|
+
steps.filter((s) => s.status === STATUS.DONE || s.status === STATUS.SKIPPED).map((s) => s.id),
|
|
83
|
+
);
|
|
84
|
+
return steps.filter(
|
|
85
|
+
(s) => s.status === STATUS.PENDING && s.deps.every((d) => satisfied.has(d)),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isComplete(steps) {
|
|
90
|
+
return steps.length > 0 && steps.every((s) => s.status === STATUS.DONE || s.status === STATUS.SKIPPED);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Stuck: there is still work pending, but nothing is runnable and nothing is in
|
|
94
|
+
// progress — so a dependency failed (or, if validate() passed, it cannot be a
|
|
95
|
+
// cycle). The orchestrator turns this into a halt rather than spinning forever.
|
|
96
|
+
function isStalled(steps) {
|
|
97
|
+
const pending = steps.filter((s) => s.status === STATUS.PENDING);
|
|
98
|
+
const running = steps.some((s) => s.status === STATUS.IN_PROGRESS);
|
|
99
|
+
return pending.length > 0 && !running && readySteps(steps).length === 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function progress(steps) {
|
|
103
|
+
const count = (st) => steps.filter((s) => s.status === st).length;
|
|
104
|
+
return {
|
|
105
|
+
total: steps.length,
|
|
106
|
+
done: count(STATUS.DONE),
|
|
107
|
+
failed: count(STATUS.FAILED),
|
|
108
|
+
inProgress: count(STATUS.IN_PROGRESS),
|
|
109
|
+
pending: count(STATUS.PENDING),
|
|
110
|
+
skipped: count(STATUS.SKIPPED),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = {
|
|
115
|
+
STATUS,
|
|
116
|
+
normalizeSteps,
|
|
117
|
+
validate,
|
|
118
|
+
hasCycle,
|
|
119
|
+
readySteps,
|
|
120
|
+
isComplete,
|
|
121
|
+
isStalled,
|
|
122
|
+
progress,
|
|
123
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// scripts/super-agent/integrate.js
|
|
2
|
+
//
|
|
3
|
+
// Branch integration for the super-agent — the piece Agent B flagged as missing:
|
|
4
|
+
// "the swarm fans out but never merges worker branches." Two parts:
|
|
5
|
+
//
|
|
6
|
+
// mergeOrder(steps) — PURE. The order to merge worker branches in, respecting
|
|
7
|
+
// step dependencies (a dependency's branch merges first).
|
|
8
|
+
// detectConflicts() — uses `git merge-tree --write-tree` to find, WITHOUT
|
|
9
|
+
// touching the working tree, which branches would conflict
|
|
10
|
+
// if merged together.
|
|
11
|
+
//
|
|
12
|
+
// The orchestrator's integrate phase uses this to decide whether to proceed
|
|
13
|
+
// (clean) or stop for a human (conflicts) — see submitIntegration.
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const { spawnSync } = require('child_process');
|
|
17
|
+
|
|
18
|
+
// Topologically order the done-with-branch steps so a dependency is merged
|
|
19
|
+
// before anything built on top of it. Pure; only reorders what is given.
|
|
20
|
+
function mergeOrder(steps) {
|
|
21
|
+
const withBranch = (steps || []).filter((s) => s.status === 'done' && s.branch);
|
|
22
|
+
const ids = new Set(withBranch.map((s) => s.id));
|
|
23
|
+
const byId = new Map(withBranch.map((s) => [s.id, s]));
|
|
24
|
+
const indeg = new Map(withBranch.map((s) => [s.id, 0]));
|
|
25
|
+
for (const s of withBranch) {
|
|
26
|
+
for (const d of s.deps || []) if (ids.has(String(d))) indeg.set(s.id, indeg.get(s.id) + 1);
|
|
27
|
+
}
|
|
28
|
+
// Stable: break ties by original order.
|
|
29
|
+
const order = [];
|
|
30
|
+
const queue = withBranch.filter((s) => indeg.get(s.id) === 0).map((s) => s.id);
|
|
31
|
+
while (queue.length) {
|
|
32
|
+
const id = queue.shift();
|
|
33
|
+
order.push(id);
|
|
34
|
+
for (const s of withBranch) {
|
|
35
|
+
if ((s.deps || []).map(String).includes(id) && indeg.has(s.id)) {
|
|
36
|
+
indeg.set(s.id, indeg.get(s.id) - 1);
|
|
37
|
+
if (indeg.get(s.id) === 0) queue.push(s.id);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Any leftover (shouldn't happen for a validated plan) appended in input order.
|
|
42
|
+
for (const s of withBranch) if (!order.includes(s.id)) order.push(s.id);
|
|
43
|
+
return order.map((id) => ({ step: id, branch: byId.get(id).branch }));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function branchNames(branches) {
|
|
47
|
+
return (branches || [])
|
|
48
|
+
.map((b) => (typeof b === 'string' ? b : b && b.branch))
|
|
49
|
+
.filter(Boolean);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Does merging branchB into branchA conflict? No working-tree side effects.
|
|
53
|
+
function pairConflicts(repoRoot, branchA, branchB) {
|
|
54
|
+
const r = spawnSync('git', ['merge-tree', '--write-tree', branchA, branchB], {
|
|
55
|
+
cwd: repoRoot, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024,
|
|
56
|
+
});
|
|
57
|
+
if (r.status === 0) return { conflict: false };
|
|
58
|
+
if (r.status === 1) return { conflict: true, files: parseConflictFiles(r.stdout) };
|
|
59
|
+
return { conflict: false, error: (r.stderr || '').trim() || `git merge-tree exited ${r.status}` };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Best-effort: pull file paths out of merge-tree's conflict section. The first
|
|
63
|
+
// line is the tree oid; conflict detail follows. Version-tolerant — we just keep
|
|
64
|
+
// anything that looks like a path, deduped.
|
|
65
|
+
function parseConflictFiles(stdout) {
|
|
66
|
+
const files = new Set();
|
|
67
|
+
for (const line of String(stdout || '').split('\n').slice(1)) {
|
|
68
|
+
const m = line.match(/(?:^|\s)([\w.\-/]+\.[\w]+)(?:\s|$)/);
|
|
69
|
+
if (m) files.add(m[1]);
|
|
70
|
+
}
|
|
71
|
+
return [...files];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Pairwise conflict scan across all worker branches. Catches the common case —
|
|
75
|
+
// two workers editing the same file — before any real merge happens. Returns
|
|
76
|
+
// { clean, conflicts: [{ branches:[a,b], files:[] }], errors: [] }.
|
|
77
|
+
function detectConflicts(repoRoot, branches) {
|
|
78
|
+
const names = branchNames(branches);
|
|
79
|
+
const conflicts = [];
|
|
80
|
+
const errors = [];
|
|
81
|
+
for (let i = 0; i < names.length; i++) {
|
|
82
|
+
for (let j = i + 1; j < names.length; j++) {
|
|
83
|
+
const c = pairConflicts(repoRoot, names[i], names[j]);
|
|
84
|
+
if (c.error) errors.push({ branches: [names[i], names[j]], error: c.error });
|
|
85
|
+
else if (c.conflict) conflicts.push({ branches: [names[i], names[j]], files: c.files || [] });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { clean: conflicts.length === 0, conflicts, errors };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { mergeOrder, detectConflicts, pairConflicts, parseConflictFiles, branchNames };
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// scripts/super-agent/orchestrator.js
|
|
2
|
+
//
|
|
3
|
+
// The super-agent driver — the piece Agent B found missing. It is an Arena-style
|
|
4
|
+
// state machine: a PURE nextAction(run) decides what to do next, and the driving
|
|
5
|
+
// LLM performs that action (decompose with blueprint, dispatch via the swarm,
|
|
6
|
+
// evaluate with the GAN loop, integrate branches, verify) and reports back
|
|
7
|
+
// through a submit* function, which advances the run and persists it.
|
|
8
|
+
//
|
|
9
|
+
// plan ──decompose──▶ execute ──(all steps done)──▶ integrate ──▶ verify ──▶ done
|
|
10
|
+
// │
|
|
11
|
+
// └─ dispatch ready steps (deps satisfied), budget-gated
|
|
12
|
+
//
|
|
13
|
+
// nextAction never mutates, so the whole control plane is tested without spawning
|
|
14
|
+
// a single agent. Mutations + phase transitions live in the submit* recorders.
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const dag = require('./dag.js');
|
|
18
|
+
const state = require('./state.js');
|
|
19
|
+
const { PHASE } = state;
|
|
20
|
+
|
|
21
|
+
// Rough per-step token estimate for the budget gate. Deliberately conservative;
|
|
22
|
+
// real spend is accrued from what the driver reports.
|
|
23
|
+
const DEFAULT_STEP_TOKENS = 4000;
|
|
24
|
+
|
|
25
|
+
function estimateTokens(steps) {
|
|
26
|
+
return (steps.length || 1) * DEFAULT_STEP_TOKENS;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function stepBrief(s) {
|
|
30
|
+
return {
|
|
31
|
+
id: s.id,
|
|
32
|
+
description: s.description || '',
|
|
33
|
+
deps: s.deps || [],
|
|
34
|
+
contextBrief: s.contextBrief || s.context || '',
|
|
35
|
+
exitCriteria: s.exitCriteria || s.exit || '',
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function branchesOf(run) {
|
|
40
|
+
return run.steps
|
|
41
|
+
.filter((s) => s.status === dag.STATUS.DONE && s.branch)
|
|
42
|
+
.map((s) => ({ step: s.id, branch: s.branch }));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── The state machine (pure) ─────────────────────────────────────────────────
|
|
46
|
+
function nextAction(run) {
|
|
47
|
+
if (!run || run.status !== 'running') {
|
|
48
|
+
return { action: 'report', reason: (run && (run.stopReason || run.status)) || 'no run', run: run && state.summarize(run) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
switch (run.phase) {
|
|
52
|
+
case PHASE.PLAN:
|
|
53
|
+
// No plan yet → ask the driver to decompose the goal into a step DAG.
|
|
54
|
+
return { action: 'decompose', goal: run.goal, scope: run.scope };
|
|
55
|
+
|
|
56
|
+
case PHASE.EXECUTE: {
|
|
57
|
+
if (dag.isStalled(run.steps)) {
|
|
58
|
+
return { action: 'halt', reason: 'plan stalled — a dependency failed, so no remaining step can run' };
|
|
59
|
+
}
|
|
60
|
+
const ready = dag.readySteps(run.steps);
|
|
61
|
+
if (ready.length === 0) {
|
|
62
|
+
// Not stalled and nothing ready → steps are in progress; wait for results.
|
|
63
|
+
return { action: 'await', inProgress: run.steps.filter((s) => s.status === dag.STATUS.IN_PROGRESS).map((s) => s.id) };
|
|
64
|
+
}
|
|
65
|
+
const afford = state.canAfford(run, estimateTokens(ready));
|
|
66
|
+
if (!afford.ok) return { action: 'halt', reason: `budget: ${afford.reason}` };
|
|
67
|
+
return { action: 'dispatch', parallel: ready.length > 1, steps: ready.map(stepBrief) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
case PHASE.INTEGRATE:
|
|
71
|
+
return { action: 'integrate', branches: branchesOf(run), scope: run.scope };
|
|
72
|
+
|
|
73
|
+
case PHASE.VERIFY:
|
|
74
|
+
return { action: 'verify', scope: run.scope };
|
|
75
|
+
|
|
76
|
+
case PHASE.DONE:
|
|
77
|
+
return { action: 'report', reason: 'done', run: state.summarize(run) };
|
|
78
|
+
|
|
79
|
+
default:
|
|
80
|
+
return { action: 'report', reason: `unknown phase: ${run.phase}`, run: state.summarize(run) };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Recorders (mutate + advance + persist) ───────────────────────────────────
|
|
85
|
+
|
|
86
|
+
// The driver decomposed the goal (via blueprint). Install the plan.
|
|
87
|
+
function submitPlan(run, rawSteps, { persist = true } = {}) {
|
|
88
|
+
requirePhase(run, PHASE.PLAN);
|
|
89
|
+
const steps = dag.normalizeSteps(rawSteps);
|
|
90
|
+
const v = dag.validate(steps);
|
|
91
|
+
if (!v.ok) throw new Error(`super-agent: invalid plan — ${v.errors.join('; ')}`);
|
|
92
|
+
if (steps.length === 0) throw new Error('super-agent: plan has no steps');
|
|
93
|
+
run.steps = steps;
|
|
94
|
+
run.phase = PHASE.EXECUTE;
|
|
95
|
+
state.record(run, { event: 'planned', steps: steps.length });
|
|
96
|
+
return persist ? state.save(run) : run;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Mark a step as about to run (so nextAction reports it as in-progress, not ready).
|
|
100
|
+
function markDispatched(run, stepIds, { persist = true } = {}) {
|
|
101
|
+
const want = new Set((Array.isArray(stepIds) ? stepIds : [stepIds]).map(String));
|
|
102
|
+
for (const s of run.steps) {
|
|
103
|
+
if (want.has(s.id) && s.status === dag.STATUS.PENDING) s.status = dag.STATUS.IN_PROGRESS;
|
|
104
|
+
}
|
|
105
|
+
state.record(run, { event: 'dispatched', steps: [...want] });
|
|
106
|
+
return persist ? state.save(run) : run;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The driver finished a step. Record result + budget, then advance the phase if
|
|
110
|
+
// the plan is now complete or stalled.
|
|
111
|
+
function submitStepResult(run, stepId, { status = dag.STATUS.DONE, branch = null, result = null, tokensSpent = 0 } = {}, { persist = true } = {}) {
|
|
112
|
+
const s = run.steps.find((x) => x.id === String(stepId));
|
|
113
|
+
if (!s) throw new Error(`super-agent: unknown step ${stepId}`);
|
|
114
|
+
s.status = status;
|
|
115
|
+
if (branch) s.branch = branch;
|
|
116
|
+
if (result != null) s.result = result;
|
|
117
|
+
state.accrue(run, { tokensSpent, steps: 1 });
|
|
118
|
+
state.record(run, { event: 'step', step: s.id, status, tokensSpent });
|
|
119
|
+
|
|
120
|
+
if (dag.isComplete(run.steps)) {
|
|
121
|
+
run.phase = PHASE.INTEGRATE;
|
|
122
|
+
} else if (dag.isStalled(run.steps)) {
|
|
123
|
+
state.stop(run, 'plan stalled after a step failed');
|
|
124
|
+
}
|
|
125
|
+
return persist ? state.save(run) : run;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The driver merged the worker branches.
|
|
129
|
+
function submitIntegration(run, { merged = true, conflicts = [], tokensSpent = 0 } = {}, { persist = true } = {}) {
|
|
130
|
+
requirePhase(run, PHASE.INTEGRATE);
|
|
131
|
+
state.accrue(run, { tokensSpent });
|
|
132
|
+
if (Array.isArray(conflicts) && conflicts.length > 0) {
|
|
133
|
+
state.record(run, { event: 'integration-conflicts', conflicts });
|
|
134
|
+
state.stop(run, `integration conflicts in: ${conflicts.join(', ')}`);
|
|
135
|
+
} else if (!merged) {
|
|
136
|
+
state.stop(run, 'integration reported not merged');
|
|
137
|
+
} else {
|
|
138
|
+
run.phase = PHASE.VERIFY;
|
|
139
|
+
state.record(run, { event: 'integrated' });
|
|
140
|
+
}
|
|
141
|
+
return persist ? state.save(run) : run;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The driver ran the verification gate (tests/build).
|
|
145
|
+
function submitVerification(run, { passed = false, detail = '', tokensSpent = 0 } = {}, { persist = true } = {}) {
|
|
146
|
+
requirePhase(run, PHASE.VERIFY);
|
|
147
|
+
state.accrue(run, { tokensSpent });
|
|
148
|
+
if (passed) {
|
|
149
|
+
run.phase = PHASE.DONE;
|
|
150
|
+
run.status = 'done';
|
|
151
|
+
state.record(run, { event: 'verified', detail });
|
|
152
|
+
} else {
|
|
153
|
+
run.status = 'failed';
|
|
154
|
+
run.stopReason = `verification failed: ${detail || 'tests/build did not pass'}`;
|
|
155
|
+
state.record(run, { event: 'verification-failed', detail });
|
|
156
|
+
}
|
|
157
|
+
return persist ? state.save(run) : run;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function requirePhase(run, expected) {
|
|
161
|
+
if (run.phase !== expected) {
|
|
162
|
+
throw new Error(`super-agent: expected phase "${expected}" but run is in "${run.phase}"`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = {
|
|
167
|
+
nextAction,
|
|
168
|
+
submitPlan,
|
|
169
|
+
markDispatched,
|
|
170
|
+
submitStepResult,
|
|
171
|
+
submitIntegration,
|
|
172
|
+
submitVerification,
|
|
173
|
+
estimateTokens,
|
|
174
|
+
DEFAULT_STEP_TOKENS,
|
|
175
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// scripts/super-agent/state.js
|
|
2
|
+
//
|
|
3
|
+
// Run lifecycle, persistence (so a run resumes after a crash or a new session),
|
|
4
|
+
// and the budget governor for the super-agent orchestrator. Mirrors the pattern
|
|
5
|
+
// of scripts/arena/state.js: the control plane is tokenless and durable, so the
|
|
6
|
+
// expensive part (actually running agents) can stop and pick up again.
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const crypto = require('crypto');
|
|
13
|
+
const safeFs = require('../lib/safe-fs.js');
|
|
14
|
+
const dag = require('./dag.js');
|
|
15
|
+
|
|
16
|
+
const RUN_DIR = process.env.KODELYTH_SUPER_AGENT_DIR
|
|
17
|
+
|| path.join(os.homedir(), '.kodelythecc', 'super-agent');
|
|
18
|
+
|
|
19
|
+
const PHASE = Object.freeze({
|
|
20
|
+
PLAN: 'plan',
|
|
21
|
+
EXECUTE: 'execute',
|
|
22
|
+
INTEGRATE: 'integrate',
|
|
23
|
+
VERIFY: 'verify',
|
|
24
|
+
DONE: 'done',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function runPath(id) {
|
|
28
|
+
return path.join(RUN_DIR, `${id}.json`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function createRun({ goal, limits = {}, scope = '.' } = {}) {
|
|
32
|
+
if (!goal || !String(goal).trim()) throw new Error('super-agent: a run needs a goal');
|
|
33
|
+
return {
|
|
34
|
+
id: crypto.randomBytes(6).toString('hex'),
|
|
35
|
+
goal: String(goal).trim(),
|
|
36
|
+
scope,
|
|
37
|
+
createdAt: new Date().toISOString(),
|
|
38
|
+
status: 'running', // running | done | failed | stopped
|
|
39
|
+
phase: PHASE.PLAN,
|
|
40
|
+
steps: [],
|
|
41
|
+
budget: {
|
|
42
|
+
// 0 / null means "no cap". Caps are the governor Agent B flagged as missing
|
|
43
|
+
// — a single per-session token-budget could not bound a whole run.
|
|
44
|
+
tokenCap: limits.tokenCap || 0,
|
|
45
|
+
stepCap: limits.stepCap || 0,
|
|
46
|
+
tokensSpent: 0,
|
|
47
|
+
stepsRun: 0,
|
|
48
|
+
},
|
|
49
|
+
history: [],
|
|
50
|
+
stopReason: null,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function record(run, event) {
|
|
55
|
+
run.history.push({ at: new Date().toISOString(), ...event });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Budget governor. Returns { ok, reason? }. Checks both the token cap and the
|
|
59
|
+
// step cap; an estimate of 0 still fails if we are already over.
|
|
60
|
+
function canAfford(run, estTokens = 0) {
|
|
61
|
+
const b = run.budget;
|
|
62
|
+
if (b.stepCap && b.stepsRun >= b.stepCap) {
|
|
63
|
+
return { ok: false, reason: `step cap reached (${b.stepsRun}/${b.stepCap})` };
|
|
64
|
+
}
|
|
65
|
+
if (b.tokenCap && b.tokensSpent + estTokens > b.tokenCap) {
|
|
66
|
+
return { ok: false, reason: `token cap would be exceeded (${b.tokensSpent}+${estTokens} > ${b.tokenCap})` };
|
|
67
|
+
}
|
|
68
|
+
return { ok: true };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function accrue(run, { tokensSpent = 0, steps = 0 } = {}) {
|
|
72
|
+
run.budget.tokensSpent += Math.max(0, tokensSpent) || 0;
|
|
73
|
+
run.budget.stepsRun += Math.max(0, steps) || 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function stop(run, reason) {
|
|
77
|
+
run.status = 'stopped';
|
|
78
|
+
run.stopReason = reason;
|
|
79
|
+
record(run, { event: 'stopped', reason });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function save(run) {
|
|
83
|
+
fs.mkdirSync(RUN_DIR, { recursive: true });
|
|
84
|
+
// Atomic: a crash mid-write must not corrupt a resumable run.
|
|
85
|
+
safeFs.replaceFileAtomic(runPath(run.id), JSON.stringify(run, null, 2) + '\n', 0o600);
|
|
86
|
+
return run;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function load(id) {
|
|
90
|
+
const raw = fs.readFileSync(runPath(id), 'utf8');
|
|
91
|
+
return JSON.parse(raw);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function exists(id) {
|
|
95
|
+
return fs.existsSync(runPath(id));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function list() {
|
|
99
|
+
if (!fs.existsSync(RUN_DIR)) return [];
|
|
100
|
+
return fs.readdirSync(RUN_DIR)
|
|
101
|
+
.filter((f) => f.endsWith('.json'))
|
|
102
|
+
.map((f) => {
|
|
103
|
+
try { return JSON.parse(fs.readFileSync(path.join(RUN_DIR, f), 'utf8')); }
|
|
104
|
+
catch { return null; }
|
|
105
|
+
})
|
|
106
|
+
.filter(Boolean)
|
|
107
|
+
.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function summarize(run) {
|
|
111
|
+
return {
|
|
112
|
+
id: run.id,
|
|
113
|
+
goal: run.goal,
|
|
114
|
+
status: run.status,
|
|
115
|
+
phase: run.phase,
|
|
116
|
+
progress: dag.progress(run.steps),
|
|
117
|
+
budget: run.budget,
|
|
118
|
+
stopReason: run.stopReason,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = {
|
|
123
|
+
PHASE,
|
|
124
|
+
RUN_DIR,
|
|
125
|
+
runPath,
|
|
126
|
+
createRun,
|
|
127
|
+
record,
|
|
128
|
+
canAfford,
|
|
129
|
+
accrue,
|
|
130
|
+
stop,
|
|
131
|
+
save,
|
|
132
|
+
load,
|
|
133
|
+
exists,
|
|
134
|
+
list,
|
|
135
|
+
summarize,
|
|
136
|
+
};
|
package/scripts/terse/ledger.js
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
// scripts/terse/ledger.js
|
|
2
|
-
// Output-token savings ledger. Stores one JSONL row per terse
|
|
3
|
-
// Zero deps.
|
|
2
|
+
// Output-token savings ledger. Stores one JSONL row per recorded terse turn.
|
|
3
|
+
// Zero deps. Writes append-only.
|
|
4
|
+
//
|
|
5
|
+
// IMPORTANT — these rows are ESTIMATES, not measured savings. There is no
|
|
6
|
+
// non-terse copy of the same reply to diff against, so `saved` is modeled:
|
|
7
|
+
// actual output tokens times a per-level baseline multiplier (RAW_MULT below).
|
|
8
|
+
// Every figure here is an estimate and each row is flagged `estimated: true`.
|
|
9
|
+
// (The Terse *file* compressor in compress.js, by contrast, reports real,
|
|
10
|
+
// measured before/after bytes.)
|
|
4
11
|
//
|
|
5
12
|
// Row shape:
|
|
6
|
-
// { ts, level, rawEstimate, actual, saved, projectHash?, source }
|
|
13
|
+
// { ts, level, rawEstimate, actual, saved, estimated, baseline, projectHash?, source }
|
|
7
14
|
//
|
|
8
15
|
// Path: ~/.kodelythecc/terse/ledger.jsonl (overridable via KODELYTH_TERSE_DIR)
|
|
9
16
|
|
|
@@ -31,8 +38,10 @@ function estimateTokens(text) {
|
|
|
31
38
|
return Math.round(Buffer.byteLength(text, 'utf8') / 4);
|
|
32
39
|
}
|
|
33
40
|
|
|
34
|
-
//
|
|
35
|
-
//
|
|
41
|
+
// Per-level baseline multiplier used to MODEL what the same reply would have
|
|
42
|
+
// cost without terse (full ≈ 2x the terse size, ultra ≈ 2.85x, lite ≈ 1.33x).
|
|
43
|
+
// These are assumptions, not measurements — every `saved` figure derived from
|
|
44
|
+
// them is an estimate, which is why each row is flagged `estimated: true`.
|
|
36
45
|
const RAW_MULT = { lite: 1.33, full: 2.0, ultra: 2.85, off: 1.0 };
|
|
37
46
|
|
|
38
47
|
function appendTurn({ actualText, level = 'full', source = 'unknown', projectHash = null }) {
|
|
@@ -46,6 +55,8 @@ function appendTurn({ actualText, level = 'full', source = 'unknown', projectHas
|
|
|
46
55
|
rawEstimate,
|
|
47
56
|
actual,
|
|
48
57
|
saved,
|
|
58
|
+
estimated: true, // modeled from RAW_MULT, not a measured A/B
|
|
59
|
+
baseline: RAW_MULT[level] || 1,
|
|
49
60
|
source,
|
|
50
61
|
...(projectHash ? { projectHash } : {}),
|
|
51
62
|
};
|
|
@@ -83,7 +83,7 @@ Example — ~70% shorter:
|
|
|
83
83
|
|
|
84
84
|
- Slash command: `/terse [lite|full|ultra|off]` — sticks for the session
|
|
85
85
|
- CLI: `kodelyth-ecc terse enable [--target claude-code|--all]` — installs the skill + command into your AI tool
|
|
86
|
-
-
|
|
86
|
+
- Dashboard (Claude Code): the **Token Savings** tab shows lifetime *estimated* output savings from the terse ledger — modeled, not measured (see the ledger note in `docs/terse-mode.md`)
|
|
87
87
|
|
|
88
88
|
## Honest numbers
|
|
89
89
|
|