kenaz 0.3.9 → 0.3.11
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/core/.claude/commands/Ansuz.md +12 -16
- package/core/.claude/hooks/memory-guard.js +13 -0
- package/core/.claude/skills/code_reviewer/SKILL.md +0 -1
- package/core/.claude/skills/code_reviewer/patterns.yaml +0 -8
- package/core/.claude/skills/dev_agent/SKILL.md +3 -6
- package/core/.claude/skills/heimdall/SKILL.md +5 -6
- package/core/.claude/skills/huginn/SKILL.md +11 -38
- package/core/.collaboration/core/task-api.js +2 -218
- package/core/.collaboration/core/task-api.test.js +1 -138
- package/core/.formation/bifrost/launch.js +2 -2
- package/core/.formation/bifrost/launch.test.js +2 -2
- package/core/.formation/bifrost/shared/ansuz-boot-policy.js +3 -3
- package/core/.formation/bifrost/shared/codex-injection.js +3 -17
- package/core/.formation/bifrost/shared/consolidate.js +1 -51
- package/core/.formation/bifrost/sync.js +4 -5
- package/core/.formation/tools/__tests__/subagent-bifrost-guard.test.js +1 -1
- package/core/.formation/tools/dispatch.js +2 -2
- package/core/.formation/tools/kenaz-stats.js +259 -0
- package/core/.formation/tools/kenaz-stats.test.js +180 -0
- package/core/.formation/tools/mcp-allowlist.test.js +5 -3
- package/core/.formation/tools/post-complete/bounded-http.js +1 -40
- package/core/.formation/tools/post-complete/bounded-http.test.js +2 -89
- package/core/.formation/tools/post-complete/cognitive.js +2 -119
- package/core/.formation/tools/post-complete/testing.js +3 -21
- package/core/.formation/tools/post-complete.js +2 -428
- package/core/.formation/tools/post-complete.test.js +0 -135
- package/core/.formation/tools/prompt.js +14 -0
- package/core/.formation/tools/route-audit.test.js +8 -20
- package/core/.rule/INDEX.yaml +3 -3
- package/core/.rule/general/ddx-synapse-stack.yaml +11 -11
- package/core/.rule/general/specialist-identity-contract.yaml +5 -5
- package/core/.rule/general/synapse-code-intelligence.yaml +25 -111
- package/core/.rule/general/worktree-isolation.yaml +2 -2
- package/core/.rule/pipeline/task-execution.yaml +6 -9
- package/core/.specialist/Amelia/README.md +5 -5
- package/core/.specialist/Amelia/capability.yaml +10 -18
- package/core/.specialist/Amelia/knowledge.yaml +15 -66
- package/core/.specialist/Amelia/workflow.yaml +12 -21
- package/core/.specialist/Ansuz/RULES.md +6 -0
- package/core/.specialist/Ansuz/capability.yaml +12 -87
- package/core/.specialist/Ansuz/concepts/autonomous_agenda.yaml +1 -1
- package/core/.specialist/Ansuz/concepts/autonomy_contract.yaml +2 -3
- package/core/.specialist/Ansuz/concepts/capability_pointers.yaml +1 -3
- package/core/.specialist/Ansuz/concepts/cognitive_fluency_contract.yaml +8 -21
- package/core/.specialist/Ansuz/concepts/cognitive_systems_overview.yaml +0 -4
- package/core/.specialist/Ansuz/concepts/cognitive_systems_turn_context.yaml +1 -1
- package/core/.specialist/Ansuz/concepts/completion_tracking.yaml +0 -1
- package/core/.specialist/Ansuz/concepts/deep_knowledge_pointers.yaml +0 -3
- package/core/.specialist/Ansuz/concepts/evolvable_guardrails.yaml +3 -3
- package/core/.specialist/Ansuz/concepts/fatigue_handoff.yaml +1 -7
- package/core/.specialist/Ansuz/concepts/guardrails_immutable.yaml +3 -4
- package/core/.specialist/Ansuz/concepts/identity.yaml +1 -2
- package/core/.specialist/Ansuz/concepts/phase9_verification.yaml +0 -1
- package/core/.specialist/Ansuz/concepts/phase_protocol.yaml +3 -4
- package/core/.specialist/Ansuz/core.yaml +3 -6
- package/core/.specialist/Ansuz/elixir.yaml +6 -6
- package/core/.specialist/Ansuz/exo_cortex_tools.yaml +5 -9
- package/core/.specialist/Ansuz/guards/blind_spot_guards.yaml +2 -2
- package/core/.specialist/Ansuz/guards/lesson_guards.yaml +6 -19
- package/core/.specialist/Ansuz/identity_contract.yaml +4 -4
- package/core/.specialist/Ansuz/knowledge.yaml +13 -40
- package/core/.specialist/Ansuz/modules/ddx.yaml +2 -2
- package/core/.specialist/Ansuz/modules/prophecy.yaml +19 -19
- package/core/.specialist/Ansuz/modules/pulse_empty.yaml +6 -17
- package/core/.specialist/Ansuz/tools/ansuz-handoff-snapshot.js +3 -102
- package/core/.specialist/Ansuz/tools/ansuz-sdk-runner.js +2 -44
- package/core/.specialist/Ansuz/workflow.yaml +32 -76
- package/core/.specialist/Forseti/core.yaml +2 -7
- package/core/.specialist/Forseti/rule.yaml +1 -1
- package/core/.specialist/Heimdall/SKILL.md +2 -3
- package/core/.specialist/Heimdall/capability.yaml +22 -45
- package/core/.specialist/Heimdall/core.yaml +24 -126
- package/core/.specialist/Heimdall/principles.yaml +8 -11
- package/core/.specialist/Heimdall/rule.yaml +2 -2
- package/core/.specialist/Huginn/capability.yaml +31 -52
- package/core/.specialist/Huginn/core.yaml +43 -123
- package/core/.specialist/Huginn/principles.yaml +5 -5
- package/core/.specialist/Huginn/rule.yaml +3 -9
- package/core/.specialist/REFLECTION_TO_CONTROL.md +3 -4
- package/core/.specialist/shared/guard_schema.json +1 -1
- package/core/.specialist/shared/principles.yaml +6 -6
- package/core/.specialist/tools/__tests__/task-2409-lessons.test.js +95 -0
- package/core/.specialist/tools/ansuz-boot-fast.js +1 -0
- package/core/.specialist/tools/lessons.js +234 -0
- package/core/.specialist/tools/phase-gate.js +10 -230
- package/core/.specialist/tools/phase-gate.test.js +2 -2
- package/core/.specialist/tools/prompt-files.test.js +1 -1
- package/core/.specialist/tools/session.js +2 -89
- package/core/.system/memory/README.md +3 -0
- package/core/CLAUDE.md +6 -7
- package/core/KENAZ_CORE_VERSION +1 -1
- package/core/dev/plans/CLI_FIRST_INSTALL_FLOW.md +26 -0
- package/core/plugins/kenaz/INVENTORY.md +1 -1
- package/core/plugins/kenaz/commands/Ansuz.md +12 -16
- package/core/plugins/kenaz/scripts/kenaz.test.js +2 -1
- package/core/plugins/kenaz/scripts/ygg.js +2 -0
- package/core/plugins/kenaz/skills/code_reviewer/SKILL.md +0 -1
- package/core/plugins/kenaz/skills/code_reviewer/patterns.yaml +0 -8
- package/core/plugins/kenaz/skills/dev_agent/SKILL.md +3 -6
- package/lib/cli.js +13 -3
- package/package.json +6 -6
- package/core/.claude/hooks/handoff-snapshot-on-decision.js +0 -188
- package/core/.claude/skills/synapse_maintain/SKILL.md +0 -84
- package/core/.formation/tools/alaya-gate.js +0 -198
- package/core/.formation/tools/post-complete/health.js +0 -464
- package/core/.formation/tools/post-complete/task2038.test.js +0 -246
- package/core/.formation/tools/post-complete/task2341.test.js +0 -74
- package/core/.rule/general/cortex-tools.yaml +0 -181
- package/core/.rule/general/memory-language.yaml +0 -67
- package/core/.rule/general/memory-source-priority.yaml +0 -207
- package/core/.rule/general/synapse-cognitive-fusion.yaml +0 -301
- package/core/.specialist/Ansuz/concepts/cognitive_systems_hippocampus.yaml +0 -14
- package/core/.specialist/Ansuz/concepts/write_verification.yaml +0 -26
- package/core/.specialist/Ansuz/modules/recall.yaml +0 -45
- package/core/.specialist/tools/MEMORY_RECALL_TRIGGERS.md +0 -156
- package/core/.specialist/tools/context-injection.js +0 -395
- package/core/dev/scripts/probe/hippocampus-concurrency.js +0 -247
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/**
|
|
4
|
+
* `kenaz stats [--days N] [--json]` (TASK_2408): is Kenaz worth it? Read-only, no Dashboard needed.
|
|
5
|
+
* Four sections, each number names its source; a missing source says "no data", never 0.
|
|
6
|
+
* 1. peer delivery <project>/.kenaz/peer/inbox.sqlite (peer_messages, direction='in'), projects from ~/.kenaz/projects.json
|
|
7
|
+
* 2. dispatch git history of the project: `Merge TASK_n` commits vs `[TASK_n]` commits (first-pass = one merge, no later fix-up on the main line)
|
|
8
|
+
* 3. field reports ~/.kenaz/cortex/field_reports.db (card_id) -> the card's merge commit -> first release tag containing it
|
|
9
|
+
* 4. alaya ~/.kenaz/cortex/global_memory.db (alaya_bijas, alaya_exported, seed_imports)
|
|
10
|
+
*/
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { execFileSync } = require('child_process');
|
|
15
|
+
|
|
16
|
+
const DAY = 86400000;
|
|
17
|
+
const NO_DATA = 'no data';
|
|
18
|
+
|
|
19
|
+
function openDb(file) {
|
|
20
|
+
if (!fs.existsSync(file)) return { err: `${NO_DATA} (${file} not found)` };
|
|
21
|
+
try {
|
|
22
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
23
|
+
return { db: new DatabaseSync(file, { readOnly: true }) };
|
|
24
|
+
} catch (e) { return { err: `${NO_DATA} (cannot read ${file}: ${['MODULE_NOT_FOUND', 'ERR_UNKNOWN_BUILTIN_MODULE'].includes(e.code) ? 'needs Node >= 22.5 for node:sqlite' : e.message})` }; }
|
|
25
|
+
}
|
|
26
|
+
function rows(db, sql, ...args) { try { return db.prepare(sql).all(...args); } catch (_) { return null; } }
|
|
27
|
+
|
|
28
|
+
const ms = (s) => { const t = Date.parse(s); return Number.isNaN(t) ? null : t; };
|
|
29
|
+
function pct(sorted, q) { return sorted.length ? sorted[Math.min(sorted.length - 1, Math.ceil(q * sorted.length) - 1)] : null; }
|
|
30
|
+
function dur(msec) {
|
|
31
|
+
if (msec == null) return NO_DATA;
|
|
32
|
+
const s = msec / 1000;
|
|
33
|
+
if (s < 90) return `${Math.round(s)}s`;
|
|
34
|
+
if (s < 5400) return `${Math.round(s / 60)}m`;
|
|
35
|
+
if (s < 129600) return `${(s / 3600).toFixed(1)}h`;
|
|
36
|
+
return `${(s / 86400).toFixed(1)}d`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Project roots whose peer inbox to read: registered projects (real paths only) plus cwd. */
|
|
40
|
+
function projectRoots(home, cwd) {
|
|
41
|
+
const out = new Set([path.resolve(cwd)]);
|
|
42
|
+
try {
|
|
43
|
+
const j = JSON.parse(fs.readFileSync(path.join(home, '.kenaz', 'projects.json'), 'utf8'));
|
|
44
|
+
for (const p of j.projects || []) if (p.path && !p.path.startsWith('__') && fs.existsSync(p.path)) out.add(path.resolve(p.path));
|
|
45
|
+
} catch (_) { /* no registry: cwd only */ }
|
|
46
|
+
return [...out];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function peerStats({ roots, now, days }) {
|
|
50
|
+
const since = now - days * DAY;
|
|
51
|
+
const src = '<project>/.kenaz/peer/inbox.sqlite peer_messages (direction=in)';
|
|
52
|
+
const msgs = [];
|
|
53
|
+
const missing = [];
|
|
54
|
+
for (const root of roots) {
|
|
55
|
+
const o = openDb(path.join(root, '.kenaz', 'peer', 'inbox.sqlite'));
|
|
56
|
+
if (!o.db) { missing.push(root); continue; }
|
|
57
|
+
const r = rows(o.db, "SELECT created_at, delivered_to_session d, delivered_at, pushed_at, delivery_state FROM peer_messages WHERE direction='in'");
|
|
58
|
+
o.db.close();
|
|
59
|
+
for (const m of r || []) if ((ms(m.created_at) || 0) >= since) msgs.push(m);
|
|
60
|
+
}
|
|
61
|
+
if (!msgs.length) return { source: src, note: `${NO_DATA} (no inbound messages in ${days}d across ${roots.length - missing.length} inbox(es))` };
|
|
62
|
+
const delivered = msgs.filter((m) => m.d === 1);
|
|
63
|
+
const push = delivered.filter((m) => m.pushed_at).length;
|
|
64
|
+
const lat = delivered.map((m) => ms(m.delivered_at) - ms(m.created_at)).filter((x) => x >= 0).sort((a, b) => a - b);
|
|
65
|
+
const reasons = {};
|
|
66
|
+
for (const m of msgs) if (m.delivery_state && m.delivery_state !== 'delivered') reasons[m.delivery_state] = (reasons[m.delivery_state] || 0) + 1;
|
|
67
|
+
return {
|
|
68
|
+
source: src, received: msgs.length, delivered: delivered.length, byPush: push, byPull: delivered.length - push,
|
|
69
|
+
pending: msgs.length - delivered.length, deliveredRate: delivered.length / msgs.length,
|
|
70
|
+
medianMs: pct(lat, 0.5), p90Ms: pct(lat, 0.9), undeliverableReasons: reasons,
|
|
71
|
+
note: 'delivery log (peer_delivery_log.rs) is in-memory only, so reasons come from peer_messages.delivery_state',
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function git(cwd, ...args) {
|
|
76
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 << 20 });
|
|
77
|
+
}
|
|
78
|
+
/** Commits of the repo (all refs): [{sha, parents, t, subject, firstParent, onHead}] newest first; null when not a git repo. */
|
|
79
|
+
function readCommits(repo, sinceMs) {
|
|
80
|
+
try {
|
|
81
|
+
const fmt = '%H%x09%P%x09%ct%x09%s';
|
|
82
|
+
const head = new Set(git(repo, 'rev-list', 'HEAD').split('\n').filter(Boolean));
|
|
83
|
+
const first = new Set(git(repo, 'log', '--first-parent', '--format=%H').split('\n').filter(Boolean));
|
|
84
|
+
return git(repo, 'log', '--all', `--since=${new Date(sinceMs).toISOString()}`, `--format=${fmt}`).split('\n').filter(Boolean).map((l) => {
|
|
85
|
+
const [sha, parents, ct, ...s] = l.split('\t');
|
|
86
|
+
return { sha, parents: parents.split(' ').filter(Boolean), t: Number(ct) * 1000, subject: s.join('\t'), firstParent: first.has(sha), onHead: head.has(sha) };
|
|
87
|
+
});
|
|
88
|
+
} catch (_) { return null; }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Commits the merged branch gained after the agent's first `task completed` commit (fixes before the merge). */
|
|
92
|
+
function branchFixups(repo, merges) {
|
|
93
|
+
let n = 0;
|
|
94
|
+
for (const m of merges) {
|
|
95
|
+
if (m.parents.length < 2) continue;
|
|
96
|
+
try {
|
|
97
|
+
const subj = git(repo, 'log', '--reverse', '--format=%s', `${m.parents[0]}..${m.parents[1]}`).split('\n').filter(Boolean);
|
|
98
|
+
const first = subj.findIndex((s) => /task completed\s*$/i.test(s));
|
|
99
|
+
// the agent's own follow-ups reuse the `task completed` subject; anything else was added after delivery
|
|
100
|
+
if (first >= 0) n += subj.slice(first + 1).filter((s) => !/task completed\s*$/i.test(s)).length;
|
|
101
|
+
} catch (_) { /* unreadable merge: not judged */ }
|
|
102
|
+
}
|
|
103
|
+
return n;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function dispatchStats({ repo, now, days }) {
|
|
107
|
+
const src = 'git log of ' + repo + ' (`Merge TASK_n` commits, `[TASK_n]` commits)';
|
|
108
|
+
const commits = readCommits(repo, now - days * DAY);
|
|
109
|
+
if (!commits) return { source: src, note: `${NO_DATA} (not a git repository)` };
|
|
110
|
+
const cards = new Map(); // n -> {merges:[commit], work:number, fixups:[]}
|
|
111
|
+
const card = (n) => { if (!cards.has(n)) cards.set(n, { merges: [], work: [], fixups: [] }); return cards.get(n); };
|
|
112
|
+
for (const c of commits) {
|
|
113
|
+
const m = /^Merge TASK_(\d+)/i.exec(c.subject);
|
|
114
|
+
if (m) { card(m[1]).merges.push(c); continue; }
|
|
115
|
+
const w = /^\[TASK_(\d+)\]/i.exec(c.subject);
|
|
116
|
+
if (w) { card(w[1]).work.push(c); if (c.firstParent && c.parents.length < 2) card(w[1]).fixups.push(c); }
|
|
117
|
+
}
|
|
118
|
+
if (!cards.size) return { source: src, note: `${NO_DATA} (no TASK commits in ${days}d)` };
|
|
119
|
+
const merged = []; const rework = []; const unmerged = []; const noMergeCommit = [];
|
|
120
|
+
for (const [n, c] of cards) {
|
|
121
|
+
const landed = c.merges.some((x) => x.onHead) || c.work.some((x) => x.onHead);
|
|
122
|
+
if (!landed) { unmerged.push(`TASK_${n}`); continue; }
|
|
123
|
+
if (!c.merges.length) { noMergeCommit.push(`TASK_${n}`); continue; }
|
|
124
|
+
merged.push(`TASK_${n}`);
|
|
125
|
+
const firstMerge = Math.min(...c.merges.map((x) => x.t));
|
|
126
|
+
const after = c.fixups.filter((x) => x.t >= firstMerge);
|
|
127
|
+
const abandoned = c.work.filter((x) => /task completed\s*$/i.test(x.subject) && !x.onHead).length;
|
|
128
|
+
const fixedOnBranch = branchFixups(repo, c.merges);
|
|
129
|
+
if (c.merges.length > 1) rework.push({ card: `TASK_${n}`, why: `re-dispatched/merged ${c.merges.length} times` });
|
|
130
|
+
else if (abandoned) rework.push({ card: `TASK_${n}`, why: `${abandoned} agent delivery commit(s) never merged (re-dispatch or a losing A/B arm)` });
|
|
131
|
+
else if (fixedOnBranch) rework.push({ card: `TASK_${n}`, why: `${fixedOnBranch} commit(s) on the branch after the agent's delivery, before the merge` });
|
|
132
|
+
else if (after.length) rework.push({ card: `TASK_${n}`, why: `${after.length} fix-up commit(s) on the main line after the merge` });
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
source: src, dispatched: cards.size, merged: merged.length, landedWithoutMergeCommit: noMergeCommit.length, notMerged: unmerged.length, firstPass: merged.length - rework.length,
|
|
136
|
+
firstPassRate: merged.length ? (merged.length - rework.length) / merged.length : null, rework, notMergedCards: unmerged.slice(0, 20),
|
|
137
|
+
note: 'dispatched = cards with any [TASK_n] or Merge TASK_n commit in the window; first-pass rate = cards with a Merge TASK_n commit on HEAD, merged once, no main-line fix-up; cards landed without a merge commit (fast-forward) are not judged; not merged = commits not reachable from HEAD (first 20 listed)',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function fieldStats({ home, repo, now, days }) {
|
|
142
|
+
const f = path.join(home, '.kenaz', 'cortex', 'field_reports.db');
|
|
143
|
+
const src = f + ' field_reports + git tags of ' + repo;
|
|
144
|
+
const o = openDb(f);
|
|
145
|
+
if (!o.db) return { source: src, note: o.err };
|
|
146
|
+
const all = rows(o.db, 'SELECT id, card_id, received_at, kind FROM field_reports WHERE quarantined = 0') || [];
|
|
147
|
+
o.db.close();
|
|
148
|
+
const reps = all.filter((r) => (ms(r.received_at) || 0) >= now - days * DAY);
|
|
149
|
+
if (!reps.length) return { source: src, note: `${NO_DATA} (no field reports in ${days}d)` };
|
|
150
|
+
let tags = null;
|
|
151
|
+
const mergeOf = {};
|
|
152
|
+
try {
|
|
153
|
+
tags = git(repo, 'for-each-ref', '--sort=creatordate', '--format=%(refname:short)\t%(creatordate:unix)', 'refs/tags/v*').split('\n').filter(Boolean).map((l) => { const [t, u] = l.split('\t'); return { tag: t, t: Number(u) * 1000 }; });
|
|
154
|
+
// closing commit of a card = newest commit on HEAD whose subject names it (Merge TASK_n, [TASK_n] or (TASK_n))
|
|
155
|
+
for (const l of git(repo, 'log', 'HEAD', '--format=%H\t%s').split('\n')) {
|
|
156
|
+
const [sha, ...sub] = l.split('\t');
|
|
157
|
+
for (const m of sub.join('\t').matchAll(/\bTASK_\d+\b/gi)) if (!mergeOf[m[0].toUpperCase()]) mergeOf[m[0].toUpperCase()] = sha;
|
|
158
|
+
}
|
|
159
|
+
} catch (_) { /* tags stay null */ }
|
|
160
|
+
// A card closed without a commit naming it (duplicate, moot, fixed under another card) is closed, not open.
|
|
161
|
+
const cardStatus = {};
|
|
162
|
+
for (const st of ['backlog', 'active', 'blocked', 'done', 'cancel', 'archived']) {
|
|
163
|
+
try { for (const f of fs.readdirSync(path.join(repo, '.kenaz', 'tasks', st))) { const m = /^(TASK_\d+)_/i.exec(f); if (m) cardStatus[m[1].toUpperCase()] = st; } } catch (_) { /* no tasks dir */ }
|
|
164
|
+
}
|
|
165
|
+
const released = []; const open = []; const closed = []; const noCard = [];
|
|
166
|
+
for (const r of reps) {
|
|
167
|
+
const recv = ms(r.received_at);
|
|
168
|
+
if (!r.card_id) { noCard.push(r.id); continue; }
|
|
169
|
+
const id = String(r.card_id).toUpperCase();
|
|
170
|
+
const sha = mergeOf[id];
|
|
171
|
+
let tag = null;
|
|
172
|
+
if (sha && tags) {
|
|
173
|
+
try { const have = new Set(git(repo, 'tag', '--contains', sha).split('\n').filter(Boolean)); tag = tags.find((t) => have.has(t.tag)) || null; } catch (_) { /* open */ }
|
|
174
|
+
}
|
|
175
|
+
const st = cardStatus[id];
|
|
176
|
+
if (tag) released.push({ id: r.id, card: r.card_id, tag: tag.tag, latencyMs: tag.t - recv });
|
|
177
|
+
else if (['done', 'cancel', 'archived'].includes(st)) closed.push({ id: r.id, card: r.card_id, status: st });
|
|
178
|
+
else open.push({ id: r.id, card: r.card_id, ageMs: now - recv, status: (sha ? 'landed, not in a release tag' : 'no commit names this card') + (st ? `; card ${st}` : '; card not found') });
|
|
179
|
+
}
|
|
180
|
+
const lat = released.map((x) => x.latencyMs).sort((a, b) => a - b);
|
|
181
|
+
return { source: src, reports: reps.length, released: released.length, medianMs: pct(lat, 0.5), releasedList: released, open, closedWithoutRelease: closed.length, closedByStatus: closed.reduce((a, c) => ({ ...a, [c.status]: (a[c.status] || 0) + 1 }), {}), withoutCard: noCard.length, note: tags ? undefined : `${NO_DATA} for release tags (not a git repository)` };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function alayaStats({ home, now, days }) {
|
|
185
|
+
const f = path.join(home, '.kenaz', 'cortex', 'global_memory.db');
|
|
186
|
+
const o = openDb(f);
|
|
187
|
+
if (!o.db) return { source: f, note: o.err };
|
|
188
|
+
const since = new Date(now - days * DAY).toISOString().slice(0, 19).replace('T', ' ');
|
|
189
|
+
const one = (sql, ...a) => { const r = rows(o.db, sql, ...a); return r && r[0] ? r[0] : null; };
|
|
190
|
+
const b = one('SELECT count(*) n, sum(activated_count > 0) active, coalesce(sum(activated_count),0) acts FROM alaya_bijas');
|
|
191
|
+
const origin = rows(o.db, 'SELECT origin, count(*) n FROM alaya_bijas GROUP BY origin');
|
|
192
|
+
const fresh = one('SELECT count(*) n FROM alaya_bijas WHERE synced_at >= ?', since);
|
|
193
|
+
const exp = one('SELECT count(*) n, sum(exported_at >= ?) w FROM alaya_exported', since);
|
|
194
|
+
const seeds = one('SELECT count(*) n FROM seed_imports WHERE imported_at >= ?', since);
|
|
195
|
+
o.db.close();
|
|
196
|
+
return {
|
|
197
|
+
source: f + ' (alaya_bijas, alaya_exported, seed_imports)', bijas: b && b.n, byOrigin: origin, bijasWithActivation: b && b.active, activationsTotal: b && b.acts,
|
|
198
|
+
syncedInWindow: fresh && fresh.n, seedsExportedTotal: exp && exp.n, seedsExportedInWindow: exp && exp.w, seedImportsInWindow: seeds && seeds.n,
|
|
199
|
+
note: 'activated_count is cumulative (no per-day record), so activations are all-time, not windowed; hub numbers: no data (no hub endpoint read)',
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function collect({ home = os.homedir(), cwd = process.cwd(), days = 14, now = Date.now() } = {}) {
|
|
204
|
+
return {
|
|
205
|
+
days,
|
|
206
|
+
peer: peerStats({ roots: projectRoots(home, cwd), now, days }),
|
|
207
|
+
dispatch: dispatchStats({ repo: cwd, now, days }),
|
|
208
|
+
fieldReports: fieldStats({ home, repo: cwd, now, days }),
|
|
209
|
+
alaya: alayaStats({ home, now, days }),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function render(s) {
|
|
214
|
+
const L = [`kenaz stats (last ${s.days} days, read-only)`, ''];
|
|
215
|
+
const p = s.peer;
|
|
216
|
+
L.push('Peer delivery', ` source: ${p.source}`);
|
|
217
|
+
if (p.received == null) L.push(` ${p.note}`);
|
|
218
|
+
else L.push(` received ${p.received}: delivered ${p.delivered} (push ${p.byPush}, pull ${p.byPull}), pending ${p.pending}; delivered rate ${(p.deliveredRate * 100).toFixed(0)}%`,
|
|
219
|
+
` time to delivery: median ${dur(p.medianMs)}, p90 ${dur(p.p90Ms)}`,
|
|
220
|
+
` undeliverable reasons: ${Object.keys(p.undeliverableReasons).length ? Object.entries(p.undeliverableReasons).map(([k, v]) => `${k}=${v}`).join(', ') : 'none recorded'}`, ` note: ${p.note}`);
|
|
221
|
+
const d = s.dispatch;
|
|
222
|
+
L.push('', 'Dispatch first-pass merge rate', ` source: ${d.source}`);
|
|
223
|
+
if (d.dispatched == null) L.push(` ${d.note}`);
|
|
224
|
+
else {
|
|
225
|
+
L.push(` cards ${d.dispatched}: merged ${d.merged}, landed without merge commit ${d.landedWithoutMergeCommit} (not judged), not merged ${d.notMerged}; first-pass ${d.firstPass}/${d.merged}${d.firstPassRate == null ? '' : ` (${(d.firstPassRate * 100).toFixed(0)}%)`}`);
|
|
226
|
+
for (const r of d.rework) L.push(` rework: ${r.card} - ${r.why}`);
|
|
227
|
+
if (d.notMergedCards.length) L.push(` not merged (first ${d.notMergedCards.length}): ${d.notMergedCards.join(', ')}`);
|
|
228
|
+
}
|
|
229
|
+
const f = s.fieldReports;
|
|
230
|
+
L.push('', 'Field report -> release latency', ` source: ${f.source}`);
|
|
231
|
+
if (f.reports == null) L.push(` ${f.note}`);
|
|
232
|
+
else {
|
|
233
|
+
L.push(` reports ${f.reports}: released ${f.released}, closed without a release ${f.closedWithoutRelease} (${Object.entries(f.closedByStatus || {}).map(([k, v]) => `${k}=${v}`).join(", ") || "-"}), open ${f.open.length}, no card yet ${f.withoutCard}; median latency ${dur(f.medianMs)}`);
|
|
234
|
+
for (const x of f.releasedList) L.push(` released: ${x.id.slice(0, 8)} ${x.card} -> ${x.tag} after ${dur(x.latencyMs)}`);
|
|
235
|
+
for (const x of f.open) L.push(` open: ${x.id.slice(0, 8)} ${x.card} age ${dur(x.ageMs)} (${x.status})`);
|
|
236
|
+
if (f.note) L.push(` note: ${f.note}`);
|
|
237
|
+
}
|
|
238
|
+
const a = s.alaya;
|
|
239
|
+
L.push('', 'Alaya (seeds / bijas)', ` source: ${a.source}`);
|
|
240
|
+
if (a.bijas == null) L.push(` ${a.note}`);
|
|
241
|
+
else {
|
|
242
|
+
L.push(` local alaya_bijas ${a.bijas} (${(a.byOrigin || []).map((o) => `${o.origin}=${o.n}`).join(', ') || NO_DATA}); synced in window ${a.syncedInWindow}`,
|
|
243
|
+
` activated: ${a.bijasWithActivation || 0} bijas with >=1 activation, ${a.activationsTotal} activations all-time`,
|
|
244
|
+
` seeds exported: ${a.seedsExportedTotal} all-time, ${a.seedsExportedInWindow || 0} in window; seed imports in window ${a.seedImportsInWindow}`, ` note: ${a.note}`);
|
|
245
|
+
}
|
|
246
|
+
return L.join('\n');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function main(argv, out = console.log, opts = {}) {
|
|
250
|
+
const i = argv.indexOf('--days');
|
|
251
|
+
const days = i >= 0 ? Number(argv[i + 1]) : 14;
|
|
252
|
+
if (!(days > 0)) { out('usage: kenaz stats [--days N] [--json]'); return 2; }
|
|
253
|
+
const s = collect({ ...opts, days });
|
|
254
|
+
out(argv.includes('--json') ? JSON.stringify(s, null, 2) : render(s));
|
|
255
|
+
return 0;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (require.main === module) process.exit(main(process.argv.slice(2)));
|
|
259
|
+
module.exports = { main, collect, peerStats, dispatchStats, fieldStats, alayaStats, render };
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// TASK_2408: kenaz stats fixture tests (temp dirs only, never the real ~/.kenaz).
|
|
2
|
+
// Usage: node .formation/tools/kenaz-stats.test.js
|
|
3
|
+
'use strict';
|
|
4
|
+
const assert = require('assert');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { execFileSync } = require('child_process');
|
|
9
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
10
|
+
const S = require('./kenaz-stats');
|
|
11
|
+
|
|
12
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kenaz-stats-'));
|
|
13
|
+
const NOW = Date.parse('2026-10-11T12:00:00Z');
|
|
14
|
+
const iso = (d, h = 0, m = 0, s = 0) => new Date(NOW - d * 86400000 + (h * 3600 + m * 60 + s) * 1000).toISOString();
|
|
15
|
+
const tests = [];
|
|
16
|
+
const test = (name, fn) => tests.push([name, fn]);
|
|
17
|
+
|
|
18
|
+
function db(file, sql) {
|
|
19
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
20
|
+
const d = new DatabaseSync(file); d.exec(sql); d.close();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test('peer: delivered by push / pull / pending, median and p90, reasons, window and "no data"', () => {
|
|
24
|
+
const root = path.join(tmp, 'peer-proj');
|
|
25
|
+
const f = path.join(root, '.kenaz', 'peer', 'inbox.sqlite');
|
|
26
|
+
const row = (id, dir, created, d, deliveredAt, pushed, state) =>
|
|
27
|
+
`INSERT INTO peer_messages(id,thread_id,from_node,to_node,kind,body,direction,created_at,delivered_to_session,delivered_at,pushed_at,delivery_state) VALUES('${id}','t','a','b','msg','x','${dir}','${created}',${d},${deliveredAt ? `'${deliveredAt}'` : 'NULL'},${pushed ? `'${pushed}'` : 'NULL'},${state ? `'${state}'` : 'NULL'});`;
|
|
28
|
+
db(f, `CREATE TABLE peer_messages(id TEXT PRIMARY KEY, thread_id TEXT, from_node TEXT, to_node TEXT, kind TEXT, body TEXT, direction TEXT, created_at TEXT, delivered_to_session INTEGER, delivered_at TEXT, pushed_at TEXT, delivery_state TEXT);` + [
|
|
29
|
+
row('1', 'in', iso(1), 1, iso(1, 0, 0, 10), iso(1), null), // push, 10s
|
|
30
|
+
row('2', 'in', iso(1), 1, iso(1, 0, 1, 0), iso(1), null), // push, 60s
|
|
31
|
+
row('3', 'in', iso(2), 1, iso(2, 1, 0, 0), null, null), // pull, 1h
|
|
32
|
+
row('4', 'in', iso(2), 0, null, null, 'waiting_no_session'), // pending, undeliverable
|
|
33
|
+
row('5', 'out', iso(1), 0, null, null, null), // outbound: ignored
|
|
34
|
+
row('6', 'in', iso(30), 1, iso(30, 0, 0, 1), iso(30), null), // outside window
|
|
35
|
+
].join(''));
|
|
36
|
+
const p = S.peerStats({ roots: [root], now: NOW, days: 14 });
|
|
37
|
+
assert.strictEqual(p.received, 4);
|
|
38
|
+
assert.strictEqual(p.delivered, 3);
|
|
39
|
+
assert.strictEqual(p.byPush, 2);
|
|
40
|
+
assert.strictEqual(p.byPull, 1);
|
|
41
|
+
assert.strictEqual(p.pending, 1);
|
|
42
|
+
assert.strictEqual(p.medianMs, 60000);
|
|
43
|
+
assert.strictEqual(p.p90Ms, 3600000);
|
|
44
|
+
assert.deepStrictEqual(p.undeliverableReasons, { waiting_no_session: 1 });
|
|
45
|
+
const none = S.peerStats({ roots: [path.join(tmp, 'absent')], now: NOW, days: 14 });
|
|
46
|
+
assert.ok(none.note.startsWith('no data') && none.received === undefined);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
let repoMemo;
|
|
50
|
+
function repo() {
|
|
51
|
+
if (repoMemo) return repoMemo;
|
|
52
|
+
const dir = path.join(tmp, 'repo');
|
|
53
|
+
fs.mkdirSync(dir);
|
|
54
|
+
const g = (...a) => execFileSync('git', a, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
55
|
+
g('init', '-q', '-b', 'main');
|
|
56
|
+
g('config', 'user.email', 't@t'); g('config', 'user.name', 't'); g('config', 'commit.gpgsign', 'false');
|
|
57
|
+
let n = 0;
|
|
58
|
+
const commit = (msg, when) => { fs.writeFileSync(path.join(dir, 'f.txt'), String(++n)); g('add', '.'); execFileSync('git', ['commit', '-q', '-m', msg], { cwd: dir, env: { ...process.env, GIT_AUTHOR_DATE: when, GIT_COMMITTER_DATE: when } }); };
|
|
59
|
+
// merge a task branch like dispatch does: branch commit + --no-ff merge
|
|
60
|
+
let b = 0;
|
|
61
|
+
const land = (card, when, extra) => {
|
|
62
|
+
const br = `task/${card}-${++b}`;
|
|
63
|
+
g('checkout', '-q', '-b', br);
|
|
64
|
+
commit(`[${card}] task completed`, when);
|
|
65
|
+
g('checkout', '-q', 'main');
|
|
66
|
+
execFileSync('git', ['merge', '--no-ff', '-q', '-m', `Merge ${card}: done`, br], { cwd: dir, env: { ...process.env, GIT_AUTHOR_DATE: when, GIT_COMMITTER_DATE: when } });
|
|
67
|
+
if (extra) commit(extra, iso(0, -5));
|
|
68
|
+
};
|
|
69
|
+
commit('base', iso(20));
|
|
70
|
+
land('TASK_1', iso(10)); // clean first pass
|
|
71
|
+
land('TASK_2', iso(9), '[TASK_2] fix-up after the merge'); // fix-up on main
|
|
72
|
+
land('TASK_3', iso(8));
|
|
73
|
+
land('TASK_3', iso(7)); // re-dispatched
|
|
74
|
+
g('checkout', '-q', '-b', 'task/TASK_4'); commit('[TASK_4] wip', iso(6)); g('checkout', '-q', 'main'); // never merged
|
|
75
|
+
return (repoMemo = { dir, g, commit });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
test('dispatch: first-pass rate, rework list (fix-up commit and re-dispatch), unmerged', () => {
|
|
79
|
+
const r = repo();
|
|
80
|
+
const d = S.dispatchStats({ repo: r.dir, now: NOW, days: 14 });
|
|
81
|
+
assert.strictEqual(d.dispatched, 4);
|
|
82
|
+
assert.strictEqual(d.merged, 3);
|
|
83
|
+
assert.deepStrictEqual(d.notMergedCards, ['TASK_4']);
|
|
84
|
+
assert.strictEqual(d.firstPass, 1);
|
|
85
|
+
assert.deepStrictEqual(d.rework.map((x) => x.card).sort(), ['TASK_2', 'TASK_3']);
|
|
86
|
+
assert.ok(Math.abs(d.firstPassRate - 1 / 3) < 1e-9);
|
|
87
|
+
assert.ok(S.dispatchStats({ repo: path.join(tmp, 'nogit'), now: NOW, days: 14 }).note.startsWith('no data'));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('field reports: latency to first release tag containing the merge, median, open with age', () => {
|
|
91
|
+
const r = repo(); // tag v1.0.0 on TASK_1's merge only
|
|
92
|
+
const mergeSha = (card) => r.g('log', '--all', '--format=%H', `--grep=^Merge ${card}:`).trim().split('\n')[0];
|
|
93
|
+
execFileSync('git', ['tag', '-a', 'v1.0.0', '-m', 'r', mergeSha('TASK_1')], { cwd: r.dir, env: { ...process.env, GIT_COMMITTER_DATE: iso(5) } });
|
|
94
|
+
const home = path.join(tmp, 'home');
|
|
95
|
+
db(path.join(home, '.kenaz', 'cortex', 'field_reports.db'), `CREATE TABLE field_reports(id TEXT PRIMARY KEY, card_id TEXT, received_at TEXT, kind TEXT, quarantined INTEGER DEFAULT 0);
|
|
96
|
+
INSERT INTO field_reports(id,card_id,received_at,kind) VALUES('aaaaaaaa-1','TASK_1','${iso(11)}','bug');
|
|
97
|
+
INSERT INTO field_reports(id,card_id,received_at,kind) VALUES('bbbbbbbb-2','TASK_2','${iso(10)}','bug');
|
|
98
|
+
INSERT INTO field_reports(id,card_id,received_at,kind) VALUES('cccccccc-3','TASK_99','${iso(3)}','bug');
|
|
99
|
+
INSERT INTO field_reports(id,card_id,received_at,kind) VALUES('dddddddd-4',NULL,'${iso(2)}','bug');
|
|
100
|
+
INSERT INTO field_reports(id,card_id,received_at,kind) VALUES('eeeeeeee-5','TASK_1','${iso(40)}','bug');`);
|
|
101
|
+
const f = S.fieldStats({ home, repo: r.dir, now: NOW, days: 14 });
|
|
102
|
+
assert.strictEqual(f.reports, 4); // 40 days old is outside the window
|
|
103
|
+
assert.strictEqual(f.released, 1);
|
|
104
|
+
assert.strictEqual(f.releasedList[0].tag, 'v1.0.0');
|
|
105
|
+
assert.strictEqual(f.medianMs, 6 * 86400000);
|
|
106
|
+
assert.deepStrictEqual(f.open.map((x) => x.card).sort(), ['TASK_2', 'TASK_99']);
|
|
107
|
+
assert.strictEqual(f.open.find((x) => x.card === 'TASK_2').ageMs, 10 * 86400000);
|
|
108
|
+
assert.strictEqual(f.withoutCard, 1);
|
|
109
|
+
assert.ok(S.fieldStats({ home: path.join(tmp, 'nohome'), repo: r.dir, now: NOW, days: 14 }).note.startsWith('no data'));
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('dispatch rework also counts an abandoned delivery and commits added on the branch after delivery; closed cards are not open', () => {
|
|
113
|
+
const dir = path.join(tmp, 'repo2');
|
|
114
|
+
fs.mkdirSync(dir);
|
|
115
|
+
const g = (...a) => execFileSync('git', a, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
116
|
+
g('init', '-q', '-b', 'main'); g('config', 'user.email', 't@t'); g('config', 'user.name', 't'); g('config', 'commit.gpgsign', 'false');
|
|
117
|
+
let n = 0;
|
|
118
|
+
const commit = (msg) => { fs.writeFileSync(path.join(dir, 'f.txt'), String(++n)); g('add', 'f.txt'); execFileSync('git', ['commit', '-q', '-m', msg], { cwd: dir, env: { ...process.env, GIT_AUTHOR_DATE: iso(3), GIT_COMMITTER_DATE: iso(3) } }); };
|
|
119
|
+
const merge = (br, card) => { g('checkout', '-q', 'main'); execFileSync('git', ['merge', '--no-ff', '-q', '-m', `Merge ${card}: x`, br], { cwd: dir, env: { ...process.env, GIT_AUTHOR_DATE: iso(2), GIT_COMMITTER_DATE: iso(2) } }); };
|
|
120
|
+
commit('base');
|
|
121
|
+
g('checkout', '-q', '-b', 'arm-a'); commit('[TASK_7] task completed'); g('checkout', '-q', 'main'); // losing A/B arm, never merged
|
|
122
|
+
g('checkout', '-q', '-b', 'arm-b'); commit('[TASK_7] task completed'); merge('arm-b', 'TASK_7');
|
|
123
|
+
g('checkout', '-q', '-b', 'b8'); commit('[TASK_8] task completed'); commit('[TASK_8] task completed'); merge('b8', 'TASK_8'); // agent's own follow-up: first pass
|
|
124
|
+
g('checkout', '-q', '-b', 'b9'); commit('[TASK_9] task completed'); commit('[TASK_9] reviewer fix before merge'); merge('b9', 'TASK_9');
|
|
125
|
+
const d = S.dispatchStats({ repo: dir, now: NOW, days: 14 });
|
|
126
|
+
const why = Object.fromEntries(d.rework.map((x) => [x.card, x.why]));
|
|
127
|
+
assert.match(why.TASK_7, /never merged/);
|
|
128
|
+
assert.match(why.TASK_9, /after the agent's delivery/);
|
|
129
|
+
assert.ok(!why.TASK_8, 'the agent re-committing its own work is still first pass');
|
|
130
|
+
// field report whose card was closed (cancel) without a commit naming it
|
|
131
|
+
fs.mkdirSync(path.join(dir, '.kenaz', 'tasks', 'cancel'), { recursive: true });
|
|
132
|
+
fs.writeFileSync(path.join(dir, '.kenaz', 'tasks', 'cancel', 'TASK_99_dup.yaml'), 'id: TASK_99\n');
|
|
133
|
+
const home = path.join(tmp, 'home2');
|
|
134
|
+
db(path.join(home, '.kenaz', 'cortex', 'field_reports.db'), `CREATE TABLE field_reports(id TEXT PRIMARY KEY, card_id TEXT, received_at TEXT, kind TEXT, quarantined INTEGER DEFAULT 0);
|
|
135
|
+
INSERT INTO field_reports(id,card_id,received_at,kind) VALUES('ffffffff-1','TASK_99','${iso(3)}','bug');
|
|
136
|
+
INSERT INTO field_reports(id,card_id,received_at,kind) VALUES('ffffffff-2','TASK_98','${iso(3)}','bug');`);
|
|
137
|
+
const f = S.fieldStats({ home, repo: dir, now: NOW, days: 14 });
|
|
138
|
+
assert.strictEqual(f.closedWithoutRelease, 1);
|
|
139
|
+
assert.deepStrictEqual(f.closedByStatus, { cancel: 1 });
|
|
140
|
+
assert.deepStrictEqual(f.open.map((x) => x.card), ['TASK_98']);
|
|
141
|
+
assert.match(f.open[0].status, /card not found/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('alaya: bija counts, activations, exported and seed imports in window; missing db says no data', () => {
|
|
145
|
+
const home = path.join(tmp, 'home2');
|
|
146
|
+
db(path.join(home, '.kenaz', 'cortex', 'global_memory.db'), `
|
|
147
|
+
CREATE TABLE alaya_bijas(id TEXT PRIMARY KEY, synced_at TEXT, activated_count INTEGER, origin TEXT);
|
|
148
|
+
CREATE TABLE alaya_exported(original_id TEXT PRIMARY KEY, exported_at TEXT);
|
|
149
|
+
CREATE TABLE seed_imports(seed_version TEXT PRIMARY KEY, imported_at TEXT);
|
|
150
|
+
INSERT INTO alaya_bijas VALUES('a','2026-10-10 00:00:00',3,'remote'),('b','2026-09-01 00:00:00',0,'remote'),('c','2026-10-09 00:00:00',2,'local');
|
|
151
|
+
INSERT INTO alaya_exported VALUES('x','2026-10-10 00:00:00'),('y','2026-08-01 00:00:00');
|
|
152
|
+
INSERT INTO seed_imports VALUES('v1','${iso(3)}');`);
|
|
153
|
+
const a = S.alayaStats({ home, now: NOW, days: 14 });
|
|
154
|
+
assert.strictEqual(a.bijas, 3);
|
|
155
|
+
assert.strictEqual(a.bijasWithActivation, 2);
|
|
156
|
+
assert.strictEqual(a.activationsTotal, 5);
|
|
157
|
+
assert.strictEqual(a.syncedInWindow, 2);
|
|
158
|
+
assert.strictEqual(a.seedsExportedTotal, 2);
|
|
159
|
+
assert.strictEqual(a.seedsExportedInWindow, 1);
|
|
160
|
+
assert.strictEqual(a.seedImportsInWindow, 1);
|
|
161
|
+
assert.ok(S.alayaStats({ home: path.join(tmp, 'nohome'), now: NOW, days: 14 }).note.startsWith('no data'));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('main: --json parses and all four sections print; bad --days exits 2', () => {
|
|
165
|
+
const lines = [];
|
|
166
|
+
assert.strictEqual(S.main(['--json'], (m) => lines.push(m), { home: path.join(tmp, 'home2'), cwd: path.join(tmp, 'repo'), now: NOW }), 0);
|
|
167
|
+
assert.deepStrictEqual(Object.keys(JSON.parse(lines[0])), ['days', 'peer', 'dispatch', 'fieldReports', 'alaya']);
|
|
168
|
+
const text = [];
|
|
169
|
+
S.main([], (m) => text.push(m), { home: path.join(tmp, 'nohome'), cwd: path.join(tmp, 'nogit'), now: NOW });
|
|
170
|
+
for (const h of ['Peer delivery', 'Dispatch first-pass merge rate', 'Field report -> release latency', 'Alaya']) assert.ok(text[0].includes(h), h);
|
|
171
|
+
assert.ok(!/\b0%/.test(text[0]) && text[0].includes('no data'));
|
|
172
|
+
assert.strictEqual(S.main(['--days', 'x'], () => {}), 2);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
let failed = 0;
|
|
176
|
+
for (const [name, fn] of tests) {
|
|
177
|
+
try { fn(); console.log('ok ' + name); } catch (e) { failed++; console.log('FAIL ' + name + '\n ' + (e.stack || e)); }
|
|
178
|
+
}
|
|
179
|
+
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) { /* best effort */ }
|
|
180
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -11,7 +11,9 @@ const MCP_RS = path.join(ROOT, 'dashboard', 'src-tauri', 'src', 'infrastructure'
|
|
|
11
11
|
const ALLOWLIST_FILES = [
|
|
12
12
|
'.specialist/Ansuz/tools/ansuz-sdk-runner.js',
|
|
13
13
|
];
|
|
14
|
-
const REMOVED = ['synapse_associate', 'synapse_audit', 'synapse_apply_usage_boosts', 'synapse_pulse', 'synapse_thought_push', 'synapse_verify', 'cortex_search'
|
|
14
|
+
const REMOVED = ['synapse_associate', 'synapse_audit', 'synapse_apply_usage_boosts', 'synapse_pulse', 'synapse_thought_push', 'synapse_verify', 'cortex_search',
|
|
15
|
+
// TASK_2411: the memory system
|
|
16
|
+
'synapse_recall', 'synapse_inscribe', 'synapse_forge', 'synapse_status', 'hippocampus_episode_start', 'cortex_recall', 'alaya_recall'];
|
|
15
17
|
|
|
16
18
|
function dispatchedTools() {
|
|
17
19
|
const src = fs.readFileSync(MCP_RS, 'utf8');
|
|
@@ -22,9 +24,9 @@ function dispatchedTools() {
|
|
|
22
24
|
return new Set([...src.slice(start, end).matchAll(/^\s*"([a-z0-9_]+)"\s*=>/gm)].map(m => m[1]));
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
test('dispatcher tool list is non-trivial and contains the
|
|
27
|
+
test('dispatcher tool list is non-trivial and contains the LSP tools and none of the removed memory tools', () => {
|
|
26
28
|
const tools = dispatchedTools();
|
|
27
|
-
for (const t of ['
|
|
29
|
+
for (const t of ['kenaz_lsp_callers', 'kenaz_ping', 'alaya_reports_list']) assert.ok(tools.has(t), t);
|
|
28
30
|
for (const t of REMOVED) assert.ok(!tools.has(t), `${t} should not be dispatched`);
|
|
29
31
|
});
|
|
30
32
|
|
|
@@ -60,43 +60,4 @@ function getJson(endpoint, opts) {
|
|
|
60
60
|
return httpJson('GET', endpoint, undefined, opts);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
if (!response.ok) return { status: 'failed', reason: response.reason };
|
|
65
|
-
const rpc = response.data;
|
|
66
|
-
if (!rpc || rpc.error || rpc.id !== id || rpc.jsonrpc !== '2.0') return { status: 'failed', reason: 'rpc_error' };
|
|
67
|
-
const result = rpc.result;
|
|
68
|
-
if (!result || (result.isError != null && result.isError !== false) || !Array.isArray(result.content)) return { status: 'failed', reason: 'mcp_error' };
|
|
69
|
-
try {
|
|
70
|
-
const texts = result.content.filter(c => c && c.type === 'text');
|
|
71
|
-
if (texts.length !== 1) throw new Error('shape');
|
|
72
|
-
const data = JSON.parse(texts[0].text);
|
|
73
|
-
if (!data || data.error || data.success === false || data.gate_fired || data.symbol_id !== args.symbol_id) throw new Error('result');
|
|
74
|
-
if (data.outcome === 'SPOOLED' && typeof data.spool_id === 'string' && data.spool_id &&
|
|
75
|
-
typeof data.spool_file === 'string' && data.spool_file) return { status: 'spooled' };
|
|
76
|
-
if (!data.outcome && typeof data.rune_id === 'string' && data.rune_id && data.type === args.type) return { status: 'successful' };
|
|
77
|
-
} catch { /* No confirmed acknowledgement. */ }
|
|
78
|
-
return { status: 'failed', reason: 'unconfirmed_result' };
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
async function inscribeBatch(items, { timeoutMs = 10000, request = postJson, endpoint = {} } = {}) {
|
|
82
|
-
const counts = { successful: 0, spooled: 0, failed: 0, skipped: 0, reason: null };
|
|
83
|
-
const deadline = Date.now() + timeoutMs;
|
|
84
|
-
for (let i = 0; i < items.length; i++) {
|
|
85
|
-
const remaining = deadline - Date.now();
|
|
86
|
-
if (remaining <= 0) { counts.reason = 'batch_deadline'; break; }
|
|
87
|
-
const id = `rune-extract-${deadline}-${i}`;
|
|
88
|
-
let verdict;
|
|
89
|
-
try {
|
|
90
|
-
const response = await request({ ...endpoint, path: '/api/mcp' }, {
|
|
91
|
-
jsonrpc: '2.0', id, method: 'tools/call', params: { name: 'synapse_inscribe', arguments: items[i] },
|
|
92
|
-
}, { timeoutMs: remaining });
|
|
93
|
-
verdict = classifyInscribe(response, id, items[i]);
|
|
94
|
-
} catch { verdict = { status: 'failed', reason: 'request_error' }; }
|
|
95
|
-
counts[verdict.status]++;
|
|
96
|
-
if (verdict.status === 'failed') { counts.reason = verdict.reason; break; }
|
|
97
|
-
}
|
|
98
|
-
counts.skipped = items.length - counts.successful - counts.spooled - counts.failed;
|
|
99
|
-
return counts;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
module.exports = { postJson, getJson, classifyInscribe, inscribeBatch };
|
|
63
|
+
module.exports = { postJson, getJson };
|
|
@@ -2,33 +2,7 @@
|
|
|
2
2
|
const { test } = require('node:test');
|
|
3
3
|
const assert = require('node:assert/strict');
|
|
4
4
|
const http = require('node:http');
|
|
5
|
-
const { postJson
|
|
6
|
-
|
|
7
|
-
test('passive extraction caps the batch at 20 and emits one truthful failure summary', async () => {
|
|
8
|
-
const fs = require('node:fs');
|
|
9
|
-
const vm = require('node:vm');
|
|
10
|
-
const sourcePath = require.resolve('./cognitive');
|
|
11
|
-
const localRequire = require('node:module').createRequire(sourcePath);
|
|
12
|
-
const mod = { exports: {} }, logs = [];
|
|
13
|
-
let batches = 0, requests = 0;
|
|
14
|
-
const ctx = { module: mod, console: { log: s => logs.push(s), warn: s => logs.push(s) },
|
|
15
|
-
require: name => name === './bounded-http' ? { inscribeBatch: async values => {
|
|
16
|
-
batches++;
|
|
17
|
-
assert.equal(values.length, 20);
|
|
18
|
-
assert.ok(values.every(v => v.projectPath === 'fixture-project'));
|
|
19
|
-
return inscribeBatch(values, { request: async () => { requests++; throw new Error('fixture'); } });
|
|
20
|
-
} } : localRequire(name),
|
|
21
|
-
};
|
|
22
|
-
vm.runInNewContext(fs.readFileSync(sourcePath, 'utf8'), ctx);
|
|
23
|
-
mod.exports.initCognitive({ getPostCompleteChangedFiles: () => Array.from({ length: 25 }, (_, i) => `file${i}.js`), stripCJK: s => s });
|
|
24
|
-
const result = await mod.exports.triggerPassiveRuneExtraction('fix: fixture', 'fixture-root', 'fixture-project');
|
|
25
|
-
assert.equal(batches, 1);
|
|
26
|
-
assert.equal(requests, 1);
|
|
27
|
-
assert.equal(result.failed, 1);
|
|
28
|
-
assert.equal(result.skipped, 19);
|
|
29
|
-
assert.equal(logs.length, 1);
|
|
30
|
-
assert.match(logs[0], /successful=0, spooled=0, failed=1, skipped=19/);
|
|
31
|
-
});
|
|
5
|
+
const { postJson } = require('./bounded-http');
|
|
32
6
|
|
|
33
7
|
async function server(t, handler) {
|
|
34
8
|
const s = http.createServer(handler);
|
|
@@ -36,54 +10,12 @@ async function server(t, handler) {
|
|
|
36
10
|
t.after(() => new Promise(resolve => { s.closeAllConnections(); s.close(resolve); }));
|
|
37
11
|
return { hostname: '127.0.0.1', port: s.address().port };
|
|
38
12
|
}
|
|
39
|
-
const items = () => [0, 1, 2].map(i => ({ symbol_id: `file${i}.js`, type: 'summary', projectPath: 'fixture-project', content: 'fixture' }));
|
|
40
|
-
function rpc(payload, data, isError = false) {
|
|
41
|
-
return { ok: true, data: { jsonrpc: '2.0', id: payload.id,
|
|
42
|
-
result: { content: [{ type: 'text', text: JSON.stringify(data) }], isError } } };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
test('written and server-spooled acknowledgements are separate; project routing survives', async () => {
|
|
46
|
-
let calls = 0;
|
|
47
|
-
const result = await inscribeBatch(items(), { request: async (_endpoint, payload) => {
|
|
48
|
-
const args = payload.params.arguments;
|
|
49
|
-
assert.equal(args.projectPath, 'fixture-project');
|
|
50
|
-
calls++;
|
|
51
|
-
if (calls === 1) return rpc(payload, { rune_id: 'rune_one', symbol_id: args.symbol_id, type: args.type });
|
|
52
|
-
if (calls === 2) return rpc(payload, { outcome: 'SPOOLED', spool_id: 'spool_one', spool_file: 'fixture-spool.json', symbol_id: args.symbol_id });
|
|
53
|
-
return rpc(payload, { error: 'rejected' }, true);
|
|
54
|
-
} });
|
|
55
|
-
assert.deepEqual(result, { successful: 1, spooled: 1, failed: 1, skipped: 0, reason: 'mcp_error' });
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
for (const fault of ['transport', 'rpc', 'mcp', 'text_json', 'unknown', 'gate', 'spool_without_receipt', 'wrong_id']) {
|
|
59
|
-
test('first ' + fault + ' failure stops batch without marking unsent writes successful', async () => {
|
|
60
|
-
let calls = 0;
|
|
61
|
-
const result = await inscribeBatch(items(), { request: async (_endpoint, payload) => {
|
|
62
|
-
calls++;
|
|
63
|
-
if (fault === 'transport') throw new Error('fixture transport');
|
|
64
|
-
if (fault === 'rpc') return { ok: true, data: { error: { code: -32000 } } };
|
|
65
|
-
if (fault === 'mcp') return rpc(payload, {}, true);
|
|
66
|
-
if (fault === 'text_json') { const r = rpc(payload, {}); r.data.result.content[0].text = 'bad'; return r; }
|
|
67
|
-
if (fault === 'gate') return rpc(payload, { gate_fired: true, rune_id: 'existing', symbol_id: 'file0.js', type: 'summary' });
|
|
68
|
-
if (fault === 'spool_without_receipt') return rpc(payload, { outcome: 'SPOOLED', symbol_id: 'file0.js' });
|
|
69
|
-
if (fault === 'wrong_id') { const r = rpc(payload, { rune_id: 'one', symbol_id: 'file0.js', type: 'summary' }); r.data.id = 'other'; return r; }
|
|
70
|
-
return rpc(payload, { success: true });
|
|
71
|
-
} });
|
|
72
|
-
assert.equal(calls, 1);
|
|
73
|
-
assert.equal(result.successful, 0);
|
|
74
|
-
assert.equal(result.spooled, 0);
|
|
75
|
-
assert.equal(result.failed, 1);
|
|
76
|
-
assert.equal(result.skipped, 2);
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
|
|
80
13
|
test('HTTP failure and invalid JSON fail closed', async t => {
|
|
81
14
|
let calls = 0;
|
|
82
15
|
const endpoint = await server(t, (_req, res) => { calls++; res.writeHead(503); res.end('{}'); });
|
|
83
|
-
const result = await
|
|
16
|
+
const result = await postJson(endpoint, {});
|
|
84
17
|
assert.equal(calls, 1);
|
|
85
18
|
assert.equal(result.reason, 'http_503');
|
|
86
|
-
assert.equal(result.skipped, 2);
|
|
87
19
|
const malformed = await server(t, (_req, res) => res.end('not-json'));
|
|
88
20
|
assert.deepEqual(await postJson(malformed, {}), { ok: false, reason: 'invalid_json' });
|
|
89
21
|
});
|
|
@@ -109,22 +41,3 @@ test('body size is bounded and disconnected response fails once', async t => {
|
|
|
109
41
|
assert.equal(result.ok, false);
|
|
110
42
|
assert.match(result.reason, /transport_error|response_aborted|response_error/);
|
|
111
43
|
});
|
|
112
|
-
|
|
113
|
-
test('batch uses one deadline and serial requests; timeout skips remaining items', async t => {
|
|
114
|
-
let calls = 0;
|
|
115
|
-
const endpoint = await server(t, (req, res) => {
|
|
116
|
-
calls++;
|
|
117
|
-
let body = '';
|
|
118
|
-
req.on('data', b => { body += b; });
|
|
119
|
-
req.on('end', () => {
|
|
120
|
-
if (calls !== 1) return;
|
|
121
|
-
const payload = JSON.parse(body);
|
|
122
|
-
setTimeout(() => res.end(JSON.stringify(rpc(payload, { rune_id: 'one', symbol_id: 'file0.js', type: 'summary' }).data)), 20);
|
|
123
|
-
});
|
|
124
|
-
});
|
|
125
|
-
const start = Date.now();
|
|
126
|
-
const result = await inscribeBatch(items(), { endpoint, timeoutMs: 100 });
|
|
127
|
-
assert.deepEqual(result, { successful: 1, spooled: 0, failed: 1, skipped: 1, reason: 'timeout' });
|
|
128
|
-
assert.equal(calls, 2);
|
|
129
|
-
assert.ok(Date.now() - start < 1000);
|
|
130
|
-
});
|