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.
@@ -0,0 +1,67 @@
1
+ // scripts/memory/instinct-inject.js
2
+ //
3
+ // Consume the structured instinct store (scripts/memory/instincts.js) at session
4
+ // start. That store is written by the correction-capture hook, but until now
5
+ // nothing ever READ it — all of its confidence / decay / outcome machinery ran
6
+ // and never influenced a single session. This module closes that loop: it picks
7
+ // the high-confidence, non-stale instincts relevant to the current project and
8
+ // formats them for injection into SessionStart context.
9
+ //
10
+ // Kept separate from session-start.js (which executes main() at import time) so
11
+ // the selection logic can be unit-tested without running the whole hook.
12
+ 'use strict';
13
+
14
+ const DEFAULT_MAX = 6;
15
+ const DEFAULT_THRESHOLD = 0.7;
16
+
17
+ // Pure selection + formatting. Given raw instinct records, keep the ones that
18
+ // are non-stale, confident enough, and in scope for `projectPath`; rank by
19
+ // confidence; cap at `max`; render as injectable markdown. No I/O — this is the
20
+ // unit the tests exercise.
21
+ function formatStructuredInstincts(instincts, { projectPath, max = DEFAULT_MAX, threshold = DEFAULT_THRESHOLD } = {}) {
22
+ const candidates = (Array.isArray(instincts) ? instincts : [])
23
+ .filter(i => i && typeof i.rule === 'string' && i.rule.trim())
24
+ .filter(i => !i.stale && (i.confidence || 0) >= threshold)
25
+ // Global instincts always apply; project-scoped ones only in their project.
26
+ // A record with no project_path is treated as unscoped (applies anywhere).
27
+ .filter(i => i.scope === 'global' || !i.project_path || i.project_path === projectPath);
28
+
29
+ if (candidates.length === 0) return '';
30
+
31
+ const ranked = candidates
32
+ .slice()
33
+ .sort((a, b) => (b.confidence || 0) - (a.confidence || 0))
34
+ .slice(0, max);
35
+
36
+ const lines = ranked.map(i => {
37
+ const scope = i.scope === 'project' ? 'project' : 'global';
38
+ const confidence = `${Math.round((i.confidence || 0) * 100)}%`;
39
+ return `- [${scope} ${confidence}] ${i.rule.trim()}`;
40
+ });
41
+
42
+ return `Learned instincts (from past corrections):\n${lines.join('\n')}`;
43
+ }
44
+
45
+ // Read the store and format. Never throws: the structured store must not be able
46
+ // to break session start, so any failure degrades to injecting nothing.
47
+ function summarizeStructuredInstincts(projectPath, { log = () => {} } = {}) {
48
+ try {
49
+ const store = require('./instincts');
50
+ const summary = formatStructuredInstincts(store.list(), { projectPath });
51
+ if (summary) {
52
+ const count = summary.split('\n').length - 1;
53
+ log(`[SessionStart] Injecting ${count} learned instinct(s) from the structured store`);
54
+ }
55
+ return summary;
56
+ } catch (err) {
57
+ log(`[SessionStart] Structured instinct injection skipped: ${err && err.message}`);
58
+ return '';
59
+ }
60
+ }
61
+
62
+ module.exports = {
63
+ formatStructuredInstincts,
64
+ summarizeStructuredInstincts,
65
+ DEFAULT_MAX,
66
+ DEFAULT_THRESHOLD,
67
+ };
@@ -0,0 +1,125 @@
1
+ // scripts/memory/pending.js
2
+ //
3
+ // The consent-respecting half of auto-capture. The Stop hook
4
+ // (hooks/memory/capture-stop.js) already AUTO-EXTRACTS memory candidates from a
5
+ // finished session and queues them to pending-review.jsonl — it never stores
6
+ // them. But nothing ever read that queue or promoted it, so the candidates piled
7
+ // up with no way to confirm them (the `/memory review-pending` the hook points at
8
+ // did not exist). This module closes the loop while honouring the memory
9
+ // protocol's rule: "never capture without explicit confirmation."
10
+ //
11
+ // listPending() — review what the session proposed
12
+ // promotePending(id) — the confirmation gate: store it (capture() redacts)
13
+ // discardPending(id) — throw it away
14
+ //
15
+ // Candidates are auto-collected; storing them is one explicit command. Nothing
16
+ // enters the recall store silently.
17
+ 'use strict';
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const crypto = require('crypto');
22
+ const safeFs = require('../lib/safe-fs.js');
23
+ const store = require('./store.js');
24
+
25
+ function pendingFile() {
26
+ return path.join(store.PATHS.dir, 'pending-review.jsonl');
27
+ }
28
+
29
+ // Stable id from content, so the same proposal queued across several sessions
30
+ // folds to one entry and the user can reference it to confirm/discard.
31
+ function candidateId(c) {
32
+ return crypto.createHash('sha256')
33
+ .update(`${(c && c.problem) || ''}::${(c && c.approach) || ''}`)
34
+ .digest('hex')
35
+ .slice(0, 8);
36
+ }
37
+
38
+ function readQueue() {
39
+ const f = pendingFile();
40
+ if (!fs.existsSync(f)) return [];
41
+ const out = [];
42
+ for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
43
+ if (!line) continue;
44
+ let row; try { row = JSON.parse(line); } catch { continue; }
45
+ if (!row || !row.problem || !row.approach) continue;
46
+ out.push(row);
47
+ }
48
+ return out;
49
+ }
50
+
51
+ function writeQueue(rows) {
52
+ const f = pendingFile();
53
+ if (rows.length === 0) {
54
+ try { fs.unlinkSync(f); } catch { /* already gone */ }
55
+ return;
56
+ }
57
+ safeFs.replaceFilePreservingMode(f, rows.map(r => JSON.stringify(r)).join('\n') + '\n');
58
+ }
59
+
60
+ // Deduped view of the queue, newest first, each with its stable id.
61
+ function listPending() {
62
+ const byId = new Map();
63
+ for (const c of readQueue()) {
64
+ const id = candidateId(c);
65
+ const prior = byId.get(id);
66
+ if (!prior || (c.queued_at || '') > (prior.queued_at || '')) {
67
+ byId.set(id, { ...c, id });
68
+ }
69
+ }
70
+ return [...byId.values()].sort((a, b) => (b.queued_at || '').localeCompare(a.queued_at || ''));
71
+ }
72
+
73
+ function countPending() {
74
+ return listPending().length;
75
+ }
76
+
77
+ function selectIds(selector) {
78
+ if (selector === 'all' || selector == null) return new Set(listPending().map(c => c.id));
79
+ const arr = Array.isArray(selector) ? selector : [selector];
80
+ return new Set(arr.map(String));
81
+ }
82
+
83
+ // The confirmation gate. Promotes the selected candidates into the recall store
84
+ // via capture() — which redacts secrets — then removes them from the queue.
85
+ // Returns the stored memories.
86
+ function promotePending(selector = 'all') {
87
+ const ids = selectIds(selector);
88
+ const promotedContentIds = new Set();
89
+ const stored = [];
90
+ for (const c of listPending()) {
91
+ if (!ids.has(c.id)) continue;
92
+ stored.push(store.capture({
93
+ problem: c.problem,
94
+ approach: c.approach,
95
+ tags: c.tags || [],
96
+ project: c.project_path || null,
97
+ language: c.language || null,
98
+ files: c.files || [],
99
+ source: 'auto-confirmed',
100
+ }));
101
+ promotedContentIds.add(c.id); // c.id === candidateId(c)
102
+ }
103
+ const remaining = readQueue().filter(c => !promotedContentIds.has(candidateId(c)));
104
+ writeQueue(remaining);
105
+ return stored;
106
+ }
107
+
108
+ // Remove candidates from the queue without storing them. Returns the count removed.
109
+ function discardPending(selector = 'all') {
110
+ const ids = selectIds(selector);
111
+ const discardContentIds = new Set(listPending().filter(c => ids.has(c.id)).map(c => c.id));
112
+ const before = readQueue();
113
+ const remaining = before.filter(c => !discardContentIds.has(candidateId(c)));
114
+ writeQueue(remaining);
115
+ return before.length - remaining.length;
116
+ }
117
+
118
+ module.exports = {
119
+ pendingFile,
120
+ candidateId,
121
+ listPending,
122
+ countPending,
123
+ promotePending,
124
+ discardPending,
125
+ };
@@ -0,0 +1,93 @@
1
+ // scripts/memory/project-id.js
2
+ //
3
+ // One project-identity resolver for the memory subsystem. Memory used to key a
4
+ // project by sha256(absolute path), while the observer keyed it by git remote —
5
+ // so the moment you cloned, moved, or renamed a repo, its absolute path changed
6
+ // and all of its memories orphaned (they were filed under the old path hash).
7
+ //
8
+ // This resolver is git-remote-first with a path fallback, and NORMALIZES the
9
+ // remote so the same repo always hashes the same way regardless of protocol,
10
+ // host form, .git suffix, embedded credentials, or trailing slash:
11
+ //
12
+ // git@github.com:owner/repo.git ─┐
13
+ // https://github.com/owner/repo/ ├─→ remote:github.com/owner/repo → one id
14
+ // ssh://git@github.com/owner/repo ─┘
15
+ //
16
+ // Backward compatibility is handled by read-both: new memories are written under
17
+ // the git-remote id, but recall also matches the LEGACY path id, so memories
18
+ // captured before this change are never lost. Zero deps, local only.
19
+ 'use strict';
20
+
21
+ const crypto = require('crypto');
22
+ const { spawnSync } = require('child_process');
23
+
24
+ function sha12(s) {
25
+ return crypto.createHash('sha256').update(String(s)).digest('hex').slice(0, 12);
26
+ }
27
+
28
+ function runGit(args, cwd) {
29
+ try {
30
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
31
+ if (r.status !== 0) return '';
32
+ return (r.stdout || '').trim();
33
+ } catch {
34
+ return '';
35
+ }
36
+ }
37
+
38
+ // Strip `://user:pass@` so a credential-bearing remote and a clean one map to the
39
+ // same project (and so a secret never ends up in a hash input we might log).
40
+ function stripRemoteCredentials(url) {
41
+ if (!url) return '';
42
+ return String(url).replace(/:\/\/[^@]+@/, '://');
43
+ }
44
+
45
+ // Canonical form of a git remote URL — see the header for the shapes this folds
46
+ // together. Returns '' for empty input.
47
+ function normalizeRemote(url) {
48
+ let u = stripRemoteCredentials(url).trim();
49
+ if (!u) return '';
50
+ u = u.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ''); // strip scheme://
51
+ u = u.replace(/^git@/i, ''); // strip scp-style user
52
+ u = u.replace(/^([^/:]+):(?!\d)/, '$1/'); // host:owner/repo → host/owner/repo (scp form, not host:port)
53
+ u = u.replace(/\.git$/i, ''); // drop a trailing .git
54
+ u = u.replace(/\/+$/, ''); // drop trailing slashes
55
+ return u.toLowerCase();
56
+ }
57
+
58
+ // The legacy, path-only id the memory store used before this change. Kept so
59
+ // memories captured under the old scheme are still found (read-both).
60
+ function legacyPathId(projectRoot) {
61
+ return sha12(projectRoot);
62
+ }
63
+
64
+ // The canonical id for a project: git-remote-first (stable across clone/move),
65
+ // path fallback (local-only repos, non-git directories).
66
+ function resolveProjectId(projectRoot, { cwd } = {}) {
67
+ if (!projectRoot) return 'global';
68
+ const dir = cwd || projectRoot;
69
+ const remote = normalizeRemote(runGit(['remote', 'get-url', 'origin'], dir));
70
+ if (remote) return sha12('remote:' + remote);
71
+ return legacyPathId(projectRoot);
72
+ }
73
+
74
+ // Every id that should match this project when recalling: the canonical id AND
75
+ // the legacy path id, de-duped. New memories are written under the first; old
76
+ // ones are still found via the second. (When the repo has no remote the two are
77
+ // identical and this collapses to one.)
78
+ function matchingProjectIds(projectRoot, opts = {}) {
79
+ if (!projectRoot) return [];
80
+ const ids = new Set();
81
+ ids.add(resolveProjectId(projectRoot, opts));
82
+ ids.add(legacyPathId(projectRoot));
83
+ return [...ids];
84
+ }
85
+
86
+ module.exports = {
87
+ sha12,
88
+ stripRemoteCredentials,
89
+ normalizeRemote,
90
+ legacyPathId,
91
+ resolveProjectId,
92
+ matchingProjectIds,
93
+ };
@@ -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: MEMORY_DIR,
31
- log: path.join(MEMORY_DIR, 'memories.jsonl'),
32
- index: path.join(MEMORY_DIR, 'index.json'),
33
- patterns: path.join(MEMORY_DIR, 'patterns.json'),
34
- projects: path.join(MEMORY_DIR, '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
- results = results.filter(m => m.project === projectFilter);
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
- problem: String(problem).slice(0, 500),
358
- approach: String(approach).slice(0, 2000),
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
- project: project ? projectHash(project) : null,
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
- const opts = { ...options, projectFilter: projectHash(projectRoot) };
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,
@@ -64,11 +64,51 @@ function detectInstalledTargets() {
64
64
  return targets;
65
65
  }
66
66
 
67
+ // Guard against the `rtk` name collision. reachingforthejack/rtk (Rust Type Kit)
68
+ // also ships a binary called `rtk`; wiring or measuring it as if it were Rust
69
+ // Token Killer would be wrong and silent — ECC would patch hooks and read a
70
+ // `gain` ledger that does not exist. Rust Token Killer is the only `rtk` with a
71
+ // `gain` subcommand (the token-savings ledger ECC reads), so probing it
72
+ // distinguishes the two with no side effects and without depending on exact
73
+ // `--version` text. `rtk gain` is the documented signature command in RTK.md.
74
+ //
75
+ // The decision is a pure function of two probe results so it can be unit-tested
76
+ // without spawning a binary:
77
+ // gainHelpOk — did `rtk gain --help` exit 0?
78
+ // helpText — stdout of `rtk --help` (or '' if it failed)
79
+ function looksLikeRealRtk({ gainHelpOk, helpText } = {}) {
80
+ if (gainHelpOk) return true;
81
+ const h = String(helpText || '');
82
+ // The token killer's help lists the `gain` command and calls itself an
83
+ // LLM-output proxy; Rust Type Kit's help does neither.
84
+ return /\bgain\b/.test(h) && /(LLM|token|proxy)/i.test(h);
85
+ }
86
+
87
+ function isRealRtk() {
88
+ let gainHelpOk = false;
89
+ try {
90
+ execFileSync('rtk', ['gain', '--help'], { stdio: 'ignore' });
91
+ gainHelpOk = true;
92
+ } catch { /* `gain` subcommand absent, or `rtk` not runnable */ }
93
+
94
+ let helpText = '';
95
+ if (!gainHelpOk) {
96
+ try {
97
+ helpText = execFileSync('rtk', ['--help'], {
98
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
99
+ });
100
+ } catch { /* no help available */ }
101
+ }
102
+ return looksLikeRealRtk({ gainHelpOk, helpText });
103
+ }
104
+
67
105
  function isInstalled() {
68
106
  try {
69
107
  execFileSync('rtk', ['--version'], { stdio: 'ignore' });
70
- return true;
71
108
  } catch { return false; }
109
+ // A binary named `rtk` exists — confirm it is Rust Token Killer, not the Rust
110
+ // Type Kit collision, before any caller wires or measures it.
111
+ return isRealRtk();
72
112
  }
73
113
 
74
114
  function getVersion() {
@@ -214,6 +254,8 @@ module.exports = {
214
254
  normalizeTarget,
215
255
  detectInstalledTargets,
216
256
  isInstalled,
257
+ isRealRtk,
258
+ looksLikeRealRtk,
217
259
  getVersion,
218
260
  install,
219
261
  enableFor,
@@ -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 };