kodelyth-ecc 2.25.0 → 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 +158 -0
- package/CLAUDE.md +2 -2
- package/README.md +2 -2
- package/agents/super-agent.md +102 -0
- package/bin/kodelyth-ecc.js +39 -1
- 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 +2 -1
- package/scripts/cli/menu.js +1 -1
- package/scripts/hooks/post-bash-command-log.js +4 -11
- package/scripts/hooks/session-start.js +13 -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 +59 -16
- 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/pending.js +125 -0
- package/scripts/memory/project-id.js +93 -0
- package/scripts/memory/store.js +123 -13
- 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/memory/store.js
CHANGED
|
@@ -19,6 +19,8 @@ const os = require('os');
|
|
|
19
19
|
const path = require('path');
|
|
20
20
|
const crypto = require('crypto');
|
|
21
21
|
const safeFs = require('../lib/safe-fs.js');
|
|
22
|
+
const pid = require('./project-id.js');
|
|
23
|
+
const { redact } = require('../lib/redact.js');
|
|
22
24
|
|
|
23
25
|
// Auto-migrate legacy ~/.kodelyth/ → ~/.kodelythecc/ before we touch any path.
|
|
24
26
|
try { require('../migrate-legacy').main(); } catch { /* best-effort */ }
|
|
@@ -27,11 +29,14 @@ const MEMORY_DIR = process.env.KODELYTH_MEMORY_DIR
|
|
|
27
29
|
|| path.join(os.homedir(), '.kodelythecc', 'memory');
|
|
28
30
|
|
|
29
31
|
const PATHS = {
|
|
30
|
-
dir:
|
|
31
|
-
log:
|
|
32
|
-
index:
|
|
33
|
-
patterns:
|
|
34
|
-
projects:
|
|
32
|
+
dir: MEMORY_DIR,
|
|
33
|
+
log: path.join(MEMORY_DIR, 'memories.jsonl'),
|
|
34
|
+
index: path.join(MEMORY_DIR, 'index.json'),
|
|
35
|
+
patterns: path.join(MEMORY_DIR, 'patterns.json'),
|
|
36
|
+
projects: path.join(MEMORY_DIR, 'projects'),
|
|
37
|
+
// Opt-in semantic layer only. Written when embeddings are enabled; absent and
|
|
38
|
+
// untouched on a default (BM25-only) install.
|
|
39
|
+
embeddings: path.join(MEMORY_DIR, 'embeddings.jsonl'),
|
|
35
40
|
};
|
|
36
41
|
|
|
37
42
|
// ── Stop words (English + common code noise) ─────────────────────────────────
|
|
@@ -208,6 +213,28 @@ function indexMemory(index, memory) {
|
|
|
208
213
|
*/
|
|
209
214
|
const RELATIVE_SCORE_FLOOR = 0.25;
|
|
210
215
|
|
|
216
|
+
// Rank fusion. BM25 decides RELEVANCE (and the floor below gates it); this only
|
|
217
|
+
// reorders the survivors so that, among similarly-relevant memories, a recent and
|
|
218
|
+
// proven one outranks an old or failed one. Pure function of one memory — the
|
|
219
|
+
// BM25 `score`, the `captured_at` timestamp, and the `resolved` outcome — so it
|
|
220
|
+
// is unit-tested directly.
|
|
221
|
+
function fuseScore(m) {
|
|
222
|
+
const base = m.score || 0;
|
|
223
|
+
// Recency: up to +25% for a just-captured memory, decaying over ~90 days.
|
|
224
|
+
let recency = 1;
|
|
225
|
+
const t = Date.parse(m.captured_at || '');
|
|
226
|
+
if (!Number.isNaN(t)) {
|
|
227
|
+
const ageDays = Math.max(0, (Date.now() - t) / 86400000);
|
|
228
|
+
recency = 1 + 0.25 * Math.exp(-ageDays / 90);
|
|
229
|
+
}
|
|
230
|
+
// Outcome: an approach confirmed to work (resolved:true) is worth more; one
|
|
231
|
+
// that didn't stick (resolved:false) is worth less. Unknown stays neutral.
|
|
232
|
+
let outcome = 1;
|
|
233
|
+
if (m.resolved === true) outcome = 1.3;
|
|
234
|
+
else if (m.resolved === false) outcome = 0.6;
|
|
235
|
+
return base * recency * outcome;
|
|
236
|
+
}
|
|
237
|
+
|
|
211
238
|
function search(query, options = {}) {
|
|
212
239
|
// minScore stays honoured when passed, so existing callers keep their exact
|
|
213
240
|
// behaviour; only the default changes from an absolute floor to a relative one.
|
|
@@ -253,9 +280,15 @@ function search(query, options = {}) {
|
|
|
253
280
|
.filter(Boolean);
|
|
254
281
|
|
|
255
282
|
if (projectFilter) {
|
|
256
|
-
|
|
283
|
+
// projectFilter may be a single id or a list of ids (read-both: the current
|
|
284
|
+
// git-remote id plus the legacy path id). Match on membership either way.
|
|
285
|
+
const allowed = Array.isArray(projectFilter) ? new Set(projectFilter) : new Set([projectFilter]);
|
|
286
|
+
results = results.filter(m => allowed.has(m.project));
|
|
257
287
|
}
|
|
258
288
|
|
|
289
|
+
// Re-rank the relevant survivors by the fused score (relevance × recency ×
|
|
290
|
+
// outcome), then trim. `score` (raw BM25) stays on each result for callers.
|
|
291
|
+
results.sort((a, b) => fuseScore(b) - fuseScore(a));
|
|
259
292
|
return results.slice(0, limit);
|
|
260
293
|
}
|
|
261
294
|
|
|
@@ -354,17 +387,23 @@ function capture({
|
|
|
354
387
|
const memory = {
|
|
355
388
|
id: newMemoryId(),
|
|
356
389
|
captured_at: new Date().toISOString(),
|
|
357
|
-
|
|
358
|
-
|
|
390
|
+
// Redact credentials BEFORE storing. A memory is written to memories.jsonl,
|
|
391
|
+
// surfaced by recall, and injected into future sessions — so a leaked key in
|
|
392
|
+
// a captured problem/approach would otherwise persist and re-surface.
|
|
393
|
+
problem: redact(String(problem)).slice(0, 500),
|
|
394
|
+
approach: redact(String(approach)).slice(0, 2000),
|
|
359
395
|
// The COUNT was capped but not the length of each tag, so a single
|
|
360
396
|
// multi-megabyte tag was stored whole — bloating the log, the index, and
|
|
361
397
|
// the memory block injected at session start. Bound both.
|
|
362
|
-
tags: Array.from(new Set(tags.map(t => String(t).slice(0, 60)))).slice(0, 20),
|
|
363
|
-
|
|
398
|
+
tags: Array.from(new Set(tags.map(t => redact(String(t)).slice(0, 60)))).slice(0, 20),
|
|
399
|
+
// Git-remote-first id: the same repo keeps its memories across clone / move /
|
|
400
|
+
// rename, where the old sha256(path) scheme orphaned them. Legacy memories
|
|
401
|
+
// (path id) are still found via read-both in recallForProject().
|
|
402
|
+
project: project ? pid.resolveProjectId(project) : null,
|
|
364
403
|
project_path: project,
|
|
365
404
|
language,
|
|
366
|
-
files: files.slice(0, 20).map(f => String(f).slice(0, 500)),
|
|
367
|
-
gotchas: gotchas.slice(0, 10).map(g => String(g).slice(0, 500)),
|
|
405
|
+
files: files.slice(0, 20).map(f => redact(String(f)).slice(0, 500)),
|
|
406
|
+
gotchas: gotchas.slice(0, 10).map(g => redact(String(g)).slice(0, 500)),
|
|
368
407
|
source,
|
|
369
408
|
};
|
|
370
409
|
// Load the index BEFORE writing the row. If index.json is missing or invalid,
|
|
@@ -383,7 +422,9 @@ function recall(query, options = {}) {
|
|
|
383
422
|
}
|
|
384
423
|
|
|
385
424
|
function recallForProject(projectRoot, query, options = {}) {
|
|
386
|
-
|
|
425
|
+
// Read-both: match the current git-remote id AND the legacy path id, so
|
|
426
|
+
// memories captured under either scheme are found.
|
|
427
|
+
const opts = { ...options, projectFilter: pid.matchingProjectIds(projectRoot) };
|
|
387
428
|
const results = search(query, opts);
|
|
388
429
|
if (results.length >= (options.limit || 5)) return results;
|
|
389
430
|
// Fall back to global memories if project-specific are sparse
|
|
@@ -525,6 +566,69 @@ function stats() {
|
|
|
525
566
|
};
|
|
526
567
|
}
|
|
527
568
|
|
|
569
|
+
// ── Optional semantic layer (opt-in; a default install never touches this) ───
|
|
570
|
+
// Embeddings live in a sidecar keyed by memory id, so memories.jsonl and its
|
|
571
|
+
// BM25 index are byte-for-byte identical whether or not embeddings are enabled.
|
|
572
|
+
|
|
573
|
+
function saveEmbedding(id, vec) {
|
|
574
|
+
if (!id || !Array.isArray(vec) || vec.length === 0) return false;
|
|
575
|
+
ensureDir(PATHS.dir);
|
|
576
|
+
fs.appendFileSync(PATHS.embeddings, JSON.stringify({ id, vec }) + '\n');
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function loadEmbeddings(ids = null) {
|
|
581
|
+
if (!fs.existsSync(PATHS.embeddings)) return {};
|
|
582
|
+
const want = ids ? new Set(ids) : null;
|
|
583
|
+
const out = {};
|
|
584
|
+
for (const line of fs.readFileSync(PATHS.embeddings, 'utf8').split('\n')) {
|
|
585
|
+
if (!line) continue;
|
|
586
|
+
let row; try { row = JSON.parse(line); } catch { continue; }
|
|
587
|
+
if (!row || !row.id || !Array.isArray(row.vec)) continue;
|
|
588
|
+
if (want && !want.has(row.id)) continue;
|
|
589
|
+
out[row.id] = row.vec; // append-only; last write wins
|
|
590
|
+
}
|
|
591
|
+
return out;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Capture a memory and, when embeddings are enabled AND a provider is available,
|
|
595
|
+
// also store its embedding. Otherwise behaves exactly like the sync capture().
|
|
596
|
+
async function captureWithEmbedding(args) {
|
|
597
|
+
const memory = capture(args);
|
|
598
|
+
try {
|
|
599
|
+
const provider = await require('./embeddings').getProvider();
|
|
600
|
+
if (provider) {
|
|
601
|
+
const vec = await provider.embed(`${memory.problem} ${memory.approach}`);
|
|
602
|
+
if (vec) saveEmbedding(memory.id, vec);
|
|
603
|
+
}
|
|
604
|
+
} catch { /* embeddings are best-effort — never fail a capture over them */ }
|
|
605
|
+
return memory;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Hybrid recall: BM25 selects the relevant set (sync, as always), then — only
|
|
609
|
+
// when embeddings are enabled and available — re-ranks that set by blending in
|
|
610
|
+
// semantic similarity. With embeddings off/unavailable this returns exactly what
|
|
611
|
+
// recall() returns.
|
|
612
|
+
async function recallHybrid(query, options = {}) {
|
|
613
|
+
const limit = options.limit || 5;
|
|
614
|
+
// Pull a wider candidate pool so semantic similarity can promote a strong
|
|
615
|
+
// match that BM25 alone ranked just below the cut.
|
|
616
|
+
const base = search(query, { ...options, limit: limit * 4 });
|
|
617
|
+
if (base.length === 0) return base;
|
|
618
|
+
try {
|
|
619
|
+
const emb = require('./embeddings');
|
|
620
|
+
const provider = await emb.getProvider();
|
|
621
|
+
if (!provider) return base.slice(0, limit);
|
|
622
|
+
const queryVec = await provider.embed(query);
|
|
623
|
+
if (!queryVec) return base.slice(0, limit);
|
|
624
|
+
const vecById = loadEmbeddings(base.map(m => m.id));
|
|
625
|
+
const fused = emb.fuseHybrid(base, { queryVec, vecById, weight: options.semanticWeight });
|
|
626
|
+
return fused.map(({ _bm25norm, _semantic, _hybrid, ...m }) => m).slice(0, limit);
|
|
627
|
+
} catch {
|
|
628
|
+
return base.slice(0, limit); // any failure in the opt-in path degrades to BM25
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
528
632
|
module.exports = {
|
|
529
633
|
PATHS,
|
|
530
634
|
capture,
|
|
@@ -536,6 +640,12 @@ module.exports = {
|
|
|
536
640
|
stats,
|
|
537
641
|
tokenise,
|
|
538
642
|
projectHash,
|
|
643
|
+
fuseScore,
|
|
644
|
+
// Opt-in semantic layer
|
|
645
|
+
saveEmbedding,
|
|
646
|
+
loadEmbeddings,
|
|
647
|
+
captureWithEmbedding,
|
|
648
|
+
recallHybrid,
|
|
539
649
|
// Improvement C
|
|
540
650
|
resolveMemory,
|
|
541
651
|
findMemoriesForFile,
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// scripts/super-agent/cli.js
|
|
2
|
+
//
|
|
3
|
+
// The driver surface for the super-agent orchestrator. Like the Arena CLI, this
|
|
4
|
+
// does NOT run agents itself — it advances a tokenless state machine and tells
|
|
5
|
+
// the driving LLM what to do next. The loop is:
|
|
6
|
+
//
|
|
7
|
+
// super-agent start --goal "..." → create a run, print the next action
|
|
8
|
+
// super-agent next <id> → print the next action (JSON)
|
|
9
|
+
// super-agent plan <id> --file p.json → install the decomposed step DAG
|
|
10
|
+
// super-agent step <id> <step> --status done|failed [--branch b] [--tokens n]
|
|
11
|
+
// super-agent integrate <id> [--conflicts a,b] [--tokens n]
|
|
12
|
+
// super-agent verify <id> --passed|--failed [--detail "..."]
|
|
13
|
+
// super-agent status <id> | list
|
|
14
|
+
//
|
|
15
|
+
// The LLM performs each action with the existing primitives (blueprint to
|
|
16
|
+
// decompose, the swarm to dispatch, the GAN loop to evaluate, git to integrate)
|
|
17
|
+
// and reports back through the submit commands.
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const state = require('./state.js');
|
|
22
|
+
const orch = require('./orchestrator.js');
|
|
23
|
+
|
|
24
|
+
function parse(argv) {
|
|
25
|
+
const positional = [];
|
|
26
|
+
const flags = {};
|
|
27
|
+
for (let i = 0; i < argv.length; i++) {
|
|
28
|
+
const a = argv[i];
|
|
29
|
+
if (a.startsWith('--')) {
|
|
30
|
+
const key = a.slice(2);
|
|
31
|
+
const next = argv[i + 1];
|
|
32
|
+
if (next && !next.startsWith('--')) { flags[key] = next; i++; }
|
|
33
|
+
else flags[key] = true;
|
|
34
|
+
} else positional.push(a);
|
|
35
|
+
}
|
|
36
|
+
return { positional, flags };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const out = (o) => process.stdout.write(JSON.stringify(o, null, 2) + '\n');
|
|
40
|
+
const err = (m) => { process.stderr.write(`super-agent: ${m}\n`); process.exit(2); };
|
|
41
|
+
|
|
42
|
+
function loadOr(id) {
|
|
43
|
+
if (!id) err('a run id is required');
|
|
44
|
+
if (!state.exists(id)) err(`no run ${id}`);
|
|
45
|
+
return state.load(id);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function main(argv) {
|
|
49
|
+
const { positional, flags } = parse(argv);
|
|
50
|
+
const cmd = positional[0];
|
|
51
|
+
|
|
52
|
+
switch (cmd) {
|
|
53
|
+
case 'start': {
|
|
54
|
+
const goal = flags.goal || positional.slice(1).join(' ');
|
|
55
|
+
if (!goal) err('usage: super-agent start --goal "<objective>" [--token-cap N] [--step-cap N] [--scope .]');
|
|
56
|
+
const run = state.createRun({
|
|
57
|
+
goal,
|
|
58
|
+
scope: flags.scope || '.',
|
|
59
|
+
limits: { tokenCap: Number(flags['token-cap']) || 0, stepCap: Number(flags['step-cap']) || 0 },
|
|
60
|
+
});
|
|
61
|
+
state.save(run);
|
|
62
|
+
out({ runId: run.id, next: orch.nextAction(run) });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
case 'next': {
|
|
66
|
+
out(orch.nextAction(loadOr(positional[1])));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
case 'plan': {
|
|
70
|
+
const run = loadOr(positional[1]);
|
|
71
|
+
if (!flags.file) err('usage: super-agent plan <id> --file <plan.json> (JSON array of { id, description, deps, exitCriteria })');
|
|
72
|
+
let steps;
|
|
73
|
+
try { steps = JSON.parse(fs.readFileSync(flags.file, 'utf8')); }
|
|
74
|
+
catch (e) { err(`cannot read plan: ${e.message}`); }
|
|
75
|
+
orch.submitPlan(run, steps);
|
|
76
|
+
out({ ok: true, next: orch.nextAction(run) });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
case 'dispatch': {
|
|
80
|
+
// Mark the given steps in-progress (optional bookkeeping before running them).
|
|
81
|
+
const run = loadOr(positional[1]);
|
|
82
|
+
const ids = (flags.steps ? String(flags.steps).split(',') : positional.slice(2)).filter(Boolean);
|
|
83
|
+
orch.markDispatched(run, ids);
|
|
84
|
+
out({ ok: true, inProgress: ids });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
case 'step': {
|
|
88
|
+
const run = loadOr(positional[1]);
|
|
89
|
+
const stepId = positional[2];
|
|
90
|
+
if (!stepId) err('usage: super-agent step <id> <stepId> --status done|failed [--branch b] [--tokens n]');
|
|
91
|
+
orch.submitStepResult(run, stepId, {
|
|
92
|
+
status: flags.status === 'failed' ? 'failed' : 'done',
|
|
93
|
+
branch: flags.branch || null,
|
|
94
|
+
result: flags.result || null,
|
|
95
|
+
tokensSpent: Number(flags.tokens) || 0,
|
|
96
|
+
});
|
|
97
|
+
out({ ok: true, next: orch.nextAction(run) });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
case 'integrate': {
|
|
101
|
+
const run = loadOr(positional[1]);
|
|
102
|
+
const conflicts = flags.conflicts ? String(flags.conflicts).split(',').filter(Boolean) : [];
|
|
103
|
+
orch.submitIntegration(run, {
|
|
104
|
+
merged: conflicts.length === 0 && flags.merged !== false,
|
|
105
|
+
conflicts,
|
|
106
|
+
tokensSpent: Number(flags.tokens) || 0,
|
|
107
|
+
});
|
|
108
|
+
out({ ok: true, next: orch.nextAction(run) });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
case 'verify': {
|
|
112
|
+
const run = loadOr(positional[1]);
|
|
113
|
+
if (!flags.passed && !flags.failed) err('usage: super-agent verify <id> --passed | --failed [--detail "..."]');
|
|
114
|
+
orch.submitVerification(run, {
|
|
115
|
+
passed: !!flags.passed && !flags.failed,
|
|
116
|
+
detail: typeof flags.detail === 'string' ? flags.detail : '',
|
|
117
|
+
tokensSpent: Number(flags.tokens) || 0,
|
|
118
|
+
});
|
|
119
|
+
out({ ok: true, next: orch.nextAction(run) });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
case 'status': {
|
|
123
|
+
out(state.summarize(loadOr(positional[1])));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
case 'list': {
|
|
127
|
+
out(state.list().map(state.summarize));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
default:
|
|
131
|
+
err('commands: start | next | plan | dispatch | step | integrate | verify | status | list');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = { main, parse };
|
|
@@ -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 };
|