kenaz 0.3.13 → 0.3.15
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 +32 -41
- package/core/.formation/bifrost/exec.js +23 -8
- package/core/.formation/bifrost/provider-launch.js +23 -11
- package/core/.formation/bifrost/provider-launch.test.js +19 -6
- package/core/.formation/tools/__tests__/diagnose-bug.test.js +73 -0
- package/core/.formation/tools/dispatch-detail/diagnose-bug.md +23 -0
- package/core/.formation/tools/dispatch.js +44 -5
- package/core/.formation/tools/kenaz-stats.js +41 -2
- package/core/.formation/tools/kenaz-stats.test.js +17 -0
- package/core/.formation/tools/prompt.js +23 -2
- package/core/.formation/tools/prompt.test.js +9 -1
- package/core/.kenaz/GLOSSARY.md +7 -0
- package/core/.specialist/Ansuz/RULES.md +3 -0
- package/core/.specialist/tools/__tests__/task-2428-glossary.test.js +126 -0
- package/core/.specialist/tools/ansuz-boot-fast.js +1 -0
- package/core/.specialist/tools/glossary.js +184 -0
- package/core/.specialist/tools/lessons-harvest.js +5 -1
- package/core/KENAZ_CORE_VERSION +1 -1
- package/core/plugins/kenaz/commands/Ansuz.md +32 -41
- package/lib/cli.js +7 -1
- package/package.json +6 -6
|
@@ -1,52 +1,43 @@
|
|
|
1
1
|
## 專案路徑
|
|
2
|
-
ARGUMENTS 含 `[專案: X]` 或 `--project-path X` → PROJECT_PATH = X,下方 boot
|
|
2
|
+
ARGUMENTS 含 `[專案: X]` 或 `--project-path X` → PROJECT_PATH = X,下方 boot、task-api、dispatch 都附加 `--project-path "<PROJECT_PATH>"`。沒有就不加。
|
|
3
3
|
|
|
4
4
|
# Ansuz boot
|
|
5
5
|
|
|
6
|
-
你是 **Ansuz**,Kenaz 的 orchestrator
|
|
7
|
-
|
|
8
|
-
## Boot(一次 Bash
|
|
9
|
-
1. Turn Context
|
|
10
|
-
2.
|
|
11
|
-
3. banner 有 `📎 Project handoff
|
|
12
|
-
4.
|
|
13
|
-
|
|
14
|
-
##
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
-
|
|
26
|
-
- subagent 回報「未驗證」的項目,由你補驗或明講仍未驗證。
|
|
27
|
-
- 合併:`git merge` 後跑相關測試(或 `node .specialist/Ansuz/tools/verify-and-land.js`),用這個專案自己的測試指令。
|
|
28
|
-
|
|
29
|
-
## 交接(handoff,ISBAR)
|
|
30
|
-
用戶說交接/暫停/換 session、context 快滿、或結束時仍有未完成的工作 → 寫交接:
|
|
31
|
-
1. `node .specialist/tools/handoff-doc.js new --topic "<主題>" --session <id>`(有 PROJECT_PATH 加 `--project-path`),建立 `<專案>/.kenaz/handoffs/<日期時間>-<主題>.md`,I 段已自動填好。
|
|
32
|
-
2. 用 Edit 填 S(現況)、B(背景:用戶決定原話、相關卡與 commit)、A(已驗證/未驗證/風險)、R(下一個 session 第一步、待用戶決定的事)。寫給「沒看過這段對話的人」,不寫空泛句。
|
|
33
|
-
3. 一份交接一個檔,不覆寫舊的;下一個 session 的 boot 會自動列出並讀最新那份。
|
|
6
|
+
你是 **Ansuz**,Kenaz 的 orchestrator:理解需求,自己動手或拆卡派給 subagent,合併前在真實路徑上驗證,然後回報。先查現況、確認 scope,再動手。
|
|
7
|
+
|
|
8
|
+
## Boot(一次 Bash)
|
|
9
|
+
1. Turn Context 有 `[session] session_id=<id>` 就記住 `<id>`。
|
|
10
|
+
2. `node .specialist/tools/ansuz-boot-fast.js`(有 `<id>` 加 `--conversation-id="<id>"`;有 PROJECT_PATH 加 `--project-path`)。
|
|
11
|
+
3. banner 有 `📎 Project handoff`:先 Read → 標記的那份全文,以它接續。
|
|
12
|
+
4. 轉述 banner,依 handoff 的 R 段與 `📋 Open cards` 提出議程。`[lessons]`、`[learn]`、`[glossary]` 行照 RULES.md 的 Lessons / Project glossary 處理。
|
|
13
|
+
|
|
14
|
+
## 工作方式
|
|
15
|
+
- **自己做**:約 30 行內的小修正 → 直接改,跑最近的測試,提交。
|
|
16
|
+
- **開卡派工**(中型、可平行、高風險):`kenaz_task_create` 開卡(requirements、acceptance criteria、scope paths)→ `node .formation/tools/dispatch.js --skill=do --task=TASK_XXX --dispatch-mode=subagent` → `promptText` 給 subagent、`agentParams` 照用(不換模型);輸出是 `executor` 區塊時照 RULES.md 的 Dispatch 執行。subagent 在 worktree commit,你驗證後合併。
|
|
17
|
+
- 開卡前確認 scope 與驗收條件對準真正的問題。
|
|
18
|
+
|
|
19
|
+
## 驗證(合併前,強度跟風險相稱)
|
|
20
|
+
- **真實路徑**:實際跑使用者會走的路,不只看單元測試。
|
|
21
|
+
- **對照組**:證明測試在舊程式上會失敗。
|
|
22
|
+
- subagent 的「未驗證」由你補驗或明講。合併後跑相關測試(含被改檔案的既有測試)。
|
|
23
|
+
|
|
24
|
+
## 交接
|
|
25
|
+
用戶說交接/暫停、context 快滿、或結束時有未完成工作:`node .specialist/tools/handoff-doc.js new --topic "<主題>" --session <id>`,再填 S/B/A/R(見 RULES.md 的 Handoff)。
|
|
34
26
|
|
|
35
27
|
## 回報與提問
|
|
36
|
-
-
|
|
37
|
-
- 需要用戶決定時用 `AskUserQuestion`(2-4
|
|
38
|
-
-
|
|
28
|
+
- 用用戶的語言(中文訊息 → 中文,含提問選項)。
|
|
29
|
+
- 需要用戶決定時用 `AskUserQuestion`(2-4 選項、建議的放第一),有證據能自己判斷的不問。
|
|
30
|
+
- 說清楚做了什麼、怎麼驗證、什麼沒驗證或要用戶決定。不說「應該可以」。
|
|
39
31
|
|
|
40
|
-
## Kenaz
|
|
41
|
-
|
|
32
|
+
## Kenaz 本身出問題:先回報再繞過
|
|
33
|
+
繞過之前先 `kenaz_wish_create`(`scope` kenaz|project;`wish_type` bug|feature|question;一行重現 + 預期 vs 實際 + `npx kenaz doctor` 版本 + session id,不貼金鑰),報完照常完成工作。
|
|
42
34
|
|
|
43
35
|
## 自主模式
|
|
44
|
-
|
|
36
|
+
用戶明確授權自主時:`node .specialist/tools/autonomy.js grant --session <id> --scope "<範圍>"`,讀 RULES.md 的 Autonomy;只在必要停點 `pause`,做完 `done`。
|
|
45
37
|
|
|
46
|
-
##
|
|
47
|
-
-
|
|
48
|
-
-
|
|
49
|
-
-
|
|
50
|
-
- banner 出現 `[learn] N history items to review`:跑 `kenaz learn review`,每個歷史項目(返工、executor 失敗、blocked 卡、handoff 風險行)有現成的 `kenaz learn add ... --source harvest:<id>`,值得記的補上 why 後執行,其餘忽略;指標建議(各 rigor 一次過率、返工最多的檔案)只建議給用戶,不自行套用。詳見 RULES.md。
|
|
38
|
+
## 教訓、詞彙、參考產品
|
|
39
|
+
- 用戶糾正、推翻決定、或驗證抓到失敗:`node .specialist/tools/lessons.js add --scope user|project --type pref|fact|pitfall|check --text "..."`,回報點名一行。
|
|
40
|
+
- 用戶定義或拍板專案術語:`kenaz glossary add "<term>" --means "..."`;問術語前先讀 `.kenaz/GLOSSARY.md`。
|
|
41
|
+
- `[rea]` 行、教訓生命週期與歷程收割:見 RULES.md(REA、Lessons)。
|
|
51
42
|
|
|
52
|
-
|
|
43
|
+
需要更多規則時(自主、派工、合併、交接、教訓、REA)讀 `.specialist/Ansuz/RULES.md`。
|
|
@@ -15,9 +15,12 @@ const os = require('os');
|
|
|
15
15
|
const path = require('path');
|
|
16
16
|
const { spawn, spawnSync } = require('child_process');
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
// First = default. From GET https://api.deepseek.com/models on 2026-10-11 (V4 ids, 1M context); the V3 ids still
|
|
19
|
+
// answer but are no longer listed. `kenaz launch` re-reads the context window from that endpoint at start.
|
|
20
|
+
const DEEPSEEK_MODELS = ['deepseek-v4-pro', 'deepseek-flash', 'deepseek-chat', 'deepseek-reasoner'];
|
|
19
21
|
const DEEPSEEK_BASE_URL = 'https://api.deepseek.com/anthropic';
|
|
20
|
-
const DEEPSEEK_CONTEXT_TOKENS = 128000;
|
|
22
|
+
const DEEPSEEK_CONTEXT_TOKENS = { 'deepseek-v4-pro': 1048576, 'deepseek-flash': 1048576, 'deepseek-chat': 128000, 'deepseek-reasoner': 128000 };
|
|
23
|
+
const DEEPSEEK_FAST = 'deepseek-flash'; // Claude Code's small/fast slot (titles, summaries): the cheap model
|
|
21
24
|
const CLAUDE_TOOLS = ['Bash', 'Edit', 'Write', 'Read', 'Glob', 'Grep'];
|
|
22
25
|
|
|
23
26
|
const codexSpec = (id) => require('./openai/relay.js').resolveCodexModelSpec(id);
|
|
@@ -66,14 +69,26 @@ function buildChild({ adapter, id, worktree, lastFile, key, gitDir }) {
|
|
|
66
69
|
/** Claude Code env for DeepSeek's Anthropic-compatible endpoint (shared by exec.js and `kenaz launch --provider deepseek`).
|
|
67
70
|
* Every model slot points at DeepSeek: background requests (small/fast = haiku) and Agent-tool subagents
|
|
68
71
|
* (sonnet/opus aliases) would otherwise ask DeepSeek for Claude model names it does not serve. */
|
|
69
|
-
function deepseekEnv(id, key) {
|
|
72
|
+
function deepseekEnv(id, key, contextTokens) {
|
|
70
73
|
return {
|
|
71
|
-
ANTHROPIC_BASE_URL: DEEPSEEK_BASE_URL, ANTHROPIC_AUTH_TOKEN: key, ANTHROPIC_MODEL: id, ANTHROPIC_SMALL_FAST_MODEL:
|
|
72
|
-
ANTHROPIC_DEFAULT_HAIKU_MODEL:
|
|
73
|
-
// Claude Code does not know DeepSeek ids and assumes a 200k window
|
|
74
|
-
|
|
74
|
+
ANTHROPIC_BASE_URL: DEEPSEEK_BASE_URL, ANTHROPIC_AUTH_TOKEN: key, ANTHROPIC_MODEL: id, ANTHROPIC_SMALL_FAST_MODEL: DEEPSEEK_FAST,
|
|
75
|
+
ANTHROPIC_DEFAULT_HAIKU_MODEL: DEEPSEEK_FAST, ANTHROPIC_DEFAULT_SONNET_MODEL: id, ANTHROPIC_DEFAULT_OPUS_MODEL: id, CLAUDE_CODE_SUBAGENT_MODEL: id,
|
|
76
|
+
// Claude Code does not know DeepSeek ids and assumes a 200k window: tell it the real one (V4 = 1M; a 128k
|
|
77
|
+
// value here made a V4 session compact right after the Ansuz boot).
|
|
78
|
+
CLAUDE_CODE_MAX_CONTEXT_TOKENS: String(contextTokens || DEEPSEEK_CONTEXT_TOKENS[id] || 128000),
|
|
75
79
|
};
|
|
76
80
|
}
|
|
81
|
+
|
|
82
|
+
/** {id: context_window} from GET /models (the live list); null on any failure (callers fall back to the table). */
|
|
83
|
+
async function deepseekModels(key, fetchFn = globalThis.fetch, timeoutMs = 3000) {
|
|
84
|
+
try {
|
|
85
|
+
const r = await fetchFn('https://api.deepseek.com/models', { headers: { Authorization: 'Bearer ' + key }, signal: AbortSignal.timeout(timeoutMs) });
|
|
86
|
+
const j = await r.json();
|
|
87
|
+
const out = {};
|
|
88
|
+
for (const m of (j && j.data) || []) if (m && m.id) out[m.id] = Number(m.context_window) || null;
|
|
89
|
+
return Object.keys(out).length ? out : null;
|
|
90
|
+
} catch { return null; }
|
|
91
|
+
}
|
|
77
92
|
const DEEPSEEK_UNSET = ['ANTHROPIC_API_KEY']; // never send a real Anthropic key to another vendor
|
|
78
93
|
|
|
79
94
|
const git = (cwd, args) => spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true });
|
|
@@ -144,4 +159,4 @@ async function main(argv) {
|
|
|
144
159
|
}
|
|
145
160
|
|
|
146
161
|
if (require.main === module) main(process.argv.slice(2)).then((c) => { process.exitCode = c; });
|
|
147
|
-
module.exports = { parseExecutorModel, deepseekKey, deepseekEnv, DEEPSEEK_UNSET, winQuote, buildChild, run, main, DEEPSEEK_MODELS };
|
|
162
|
+
module.exports = { parseExecutorModel, deepseekKey, deepseekEnv, deepseekModels, DEEPSEEK_UNSET, winQuote, buildChild, run, main, DEEPSEEK_MODELS };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
/**
|
|
4
|
-
* `kenaz launch --provider deepseek [--model deepseek-
|
|
4
|
+
* `kenaz launch --provider deepseek [--model deepseek-v4-pro|deepseek-flash|...] [claude args...]`
|
|
5
5
|
* Starts Claude Code with every model request sent to another vendor's Anthropic-compatible endpoint, so
|
|
6
6
|
* Ansuz (/kenaz:Ansuz), its subagents and background requests run on that vendor and use no Claude quota.
|
|
7
7
|
* The key is read from the env or ~/.kenaz/credentials.yaml and set only in the claude child's env: never in
|
|
@@ -9,18 +9,17 @@
|
|
|
9
9
|
*/
|
|
10
10
|
const os = require('os');
|
|
11
11
|
const { spawn } = require('child_process');
|
|
12
|
-
const { deepseekKey, deepseekEnv, DEEPSEEK_UNSET, DEEPSEEK_MODELS, winQuote } = require('./exec');
|
|
12
|
+
const { deepseekKey, deepseekEnv, deepseekModels, DEEPSEEK_UNSET, DEEPSEEK_MODELS, winQuote } = require('./exec');
|
|
13
13
|
|
|
14
14
|
const PROVIDERS = {
|
|
15
15
|
deepseek: {
|
|
16
|
-
models: DEEPSEEK_MODELS, key: deepseekKey, env: deepseekEnv, unset: DEEPSEEK_UNSET,
|
|
16
|
+
models: DEEPSEEK_MODELS, key: deepseekKey, env: deepseekEnv, live: deepseekModels, unset: DEEPSEEK_UNSET,
|
|
17
17
|
keyHelp: 'set DEEPSEEK_API_KEY, or add `deepseek_api_key: <key>` to ~/.kenaz/credentials.yaml',
|
|
18
18
|
},
|
|
19
19
|
};
|
|
20
20
|
const USAGE = 'usage: kenaz launch --provider <' + Object.keys(PROVIDERS).join('|') + '> [--model <id>] [claude args...]';
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
function plan(argv, env = process.env, home = os.homedir()) {
|
|
22
|
+
function parse(argv) {
|
|
24
23
|
const rest = []; let provider = null; let model = null;
|
|
25
24
|
for (let i = 0; i < argv.length; i++) {
|
|
26
25
|
const m = /^--(provider|model)(?:=(.*))?$/.exec(argv[i]);
|
|
@@ -28,22 +27,35 @@ function plan(argv, env = process.env, home = os.homedir()) {
|
|
|
28
27
|
const v = m[2] !== undefined ? m[2] : argv[++i];
|
|
29
28
|
if (m[1] === 'provider') provider = v; else model = v;
|
|
30
29
|
}
|
|
30
|
+
return { rest, provider, model };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Child process plan: { bin, args, env }. live = {id: context_window} from the vendor's model list (or null):
|
|
34
|
+
* a listed id is valid even if it is newer than the built-in list, and its window drives auto-compact.
|
|
35
|
+
* Throws with a user-facing message. */
|
|
36
|
+
function plan(argv, env = process.env, home = os.homedir(), live = null) {
|
|
37
|
+
const { rest, provider, model } = parse(argv);
|
|
31
38
|
const p = PROVIDERS[provider];
|
|
32
39
|
if (!p) throw new Error((provider ? `unknown provider '${provider}'. ` : '') + USAGE);
|
|
33
40
|
const id = model || p.models[0];
|
|
34
|
-
|
|
41
|
+
const valid = [...new Set([...p.models, ...Object.keys(live || {})])];
|
|
42
|
+
if (!valid.includes(id)) throw new Error(`unknown ${provider} model '${id}'; valid: ${valid.join(', ')}`);
|
|
35
43
|
const key = p.key(env, home);
|
|
36
44
|
if (!key) throw new Error(`${provider}: no API key (${p.keyHelp})`);
|
|
37
|
-
const childEnv = { ...env, ...p.env(id, key), CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' };
|
|
45
|
+
const childEnv = { ...env, ...p.env(id, key, live && live[id]), CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' };
|
|
38
46
|
for (const k of p.unset) delete childEnv[k];
|
|
39
|
-
return { bin: 'claude', args: rest, env: childEnv, provider, model: id };
|
|
47
|
+
return { bin: 'claude', args: rest, env: childEnv, provider, model: id, context: childEnv.CLAUDE_CODE_MAX_CONTEXT_TOKENS };
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
/** Resolves to the child's exit code. deps: { spawn, env, home, platform } for tests. */
|
|
43
|
-
function main(argv, out = console.log, deps = {}) {
|
|
51
|
+
async function main(argv, out = console.log, deps = {}) {
|
|
52
|
+
const env = deps.env || process.env, home = deps.home || os.homedir();
|
|
53
|
+
const p = PROVIDERS[parse(argv).provider];
|
|
54
|
+
const key = p && p.key(env, home);
|
|
55
|
+
const live = key ? await (deps.live || p.live)(key) : null;
|
|
44
56
|
let pl;
|
|
45
|
-
try { pl = plan(argv,
|
|
46
|
-
out(`kenaz launch: Claude Code on ${pl.provider} (${pl.model}); no Claude quota is used in this session`);
|
|
57
|
+
try { pl = plan(argv, env, home, live); } catch (e) { out(e.message); return 2; }
|
|
58
|
+
out(`kenaz launch: Claude Code on ${pl.provider} (${pl.model}, context ${pl.context} tokens); no Claude quota is used in this session`);
|
|
47
59
|
const win = (deps.platform || process.platform) === 'win32'; // claude is a .cmd shim on npm installs
|
|
48
60
|
const child = (deps.spawn || spawn)(win ? [pl.bin, ...pl.args].map(winQuote).join(' ') : pl.bin, win ? [] : pl.args,
|
|
49
61
|
{ stdio: 'inherit', env: pl.env, shell: win });
|
|
@@ -16,7 +16,7 @@ function fakeSpawn(calls, code = 0) {
|
|
|
16
16
|
test('deepseek: every model slot points at DeepSeek, key only in the child env, Anthropic key removed', async () => {
|
|
17
17
|
const calls = [], lines = [];
|
|
18
18
|
const rc = await main(['--provider', 'deepseek', '--resume', 'abc'], (l) => lines.push(l),
|
|
19
|
-
{ spawn: fakeSpawn(calls), env: { DEEPSEEK_API_KEY: KEY, ANTHROPIC_API_KEY: 'real-anthropic', PATH: 'p' }, home: home(), platform: 'linux' });
|
|
19
|
+
{ spawn: fakeSpawn(calls), live: async () => null, env: { DEEPSEEK_API_KEY: KEY, ANTHROPIC_API_KEY: 'real-anthropic', PATH: 'p' }, home: home(), platform: 'linux' });
|
|
20
20
|
assert.strictEqual(rc, 0);
|
|
21
21
|
const c = calls[0];
|
|
22
22
|
assert.strictEqual(c.cmd, 'claude');
|
|
@@ -24,9 +24,11 @@ test('deepseek: every model slot points at DeepSeek, key only in the child env,
|
|
|
24
24
|
const e = c.opts.env;
|
|
25
25
|
assert.strictEqual(e.ANTHROPIC_BASE_URL, 'https://api.deepseek.com/anthropic');
|
|
26
26
|
assert.strictEqual(e.ANTHROPIC_AUTH_TOKEN, KEY);
|
|
27
|
-
for (const k of ['ANTHROPIC_MODEL', '
|
|
28
|
-
assert.strictEqual(e[k], 'deepseek-
|
|
27
|
+
for (const k of ['ANTHROPIC_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL']) {
|
|
28
|
+
assert.strictEqual(e[k], 'deepseek-v4-pro', k);
|
|
29
29
|
}
|
|
30
|
+
assert.strictEqual(e.ANTHROPIC_SMALL_FAST_MODEL, 'deepseek-flash'); assert.strictEqual(e.ANTHROPIC_DEFAULT_HAIKU_MODEL, 'deepseek-flash');
|
|
31
|
+
assert.strictEqual(e.CLAUDE_CODE_MAX_CONTEXT_TOKENS, '1048576', 'V4 window from the built-in table when the live list is unavailable');
|
|
30
32
|
assert.strictEqual(e.ANTHROPIC_API_KEY, undefined);
|
|
31
33
|
assert.strictEqual(e.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, '1');
|
|
32
34
|
assert.strictEqual(e.PATH, 'p', 'the rest of the env is kept');
|
|
@@ -37,11 +39,12 @@ test('--model, key from ~/.kenaz/credentials.yaml, Windows goes through the shel
|
|
|
37
39
|
const h = home(); fs.mkdirSync(path.join(h, '.kenaz'));
|
|
38
40
|
fs.writeFileSync(path.join(h, '.kenaz', 'credentials.yaml'), `relay: x\ndeepseek_api_key: "${KEY}"\n`);
|
|
39
41
|
const calls = [];
|
|
40
|
-
const rc = await main(['--provider=deepseek', '--model', 'deepseek-reasoner'], () => {}, { spawn: fakeSpawn(calls), env: {}, home: h, platform: 'win32' });
|
|
42
|
+
const rc = await main(['--provider=deepseek', '--model', 'deepseek-reasoner'], () => {}, { spawn: fakeSpawn(calls), live: async () => null, env: {}, home: h, platform: 'win32' });
|
|
41
43
|
assert.strictEqual(rc, 0);
|
|
42
44
|
assert.strictEqual(calls[0].cmd, 'claude');
|
|
43
45
|
assert.strictEqual(calls[0].opts.shell, true);
|
|
44
46
|
assert.strictEqual(calls[0].opts.env.ANTHROPIC_MODEL, 'deepseek-reasoner');
|
|
47
|
+
assert.strictEqual(calls[0].opts.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS, '128000', 'legacy V3 id keeps its 128k window');
|
|
45
48
|
assert.ok(!calls[0].cmd.includes(KEY));
|
|
46
49
|
});
|
|
47
50
|
|
|
@@ -50,13 +53,23 @@ test('control: no key, unknown provider or model -> exit 2 and claude is never s
|
|
|
50
53
|
[['--provider', 'deepseek'], {}, /no API key/],
|
|
51
54
|
[['--provider', 'nope'], { DEEPSEEK_API_KEY: KEY }, /unknown provider 'nope'/],
|
|
52
55
|
[[], { DEEPSEEK_API_KEY: KEY }, /usage: kenaz launch --provider <deepseek>/],
|
|
53
|
-
[['--provider', 'deepseek', '--model', 'gpt-x'], { DEEPSEEK_API_KEY: KEY }, /valid: deepseek-chat, deepseek-reasoner/],
|
|
56
|
+
[['--provider', 'deepseek', '--model', 'gpt-x'], { DEEPSEEK_API_KEY: KEY }, /valid: deepseek-v4-pro, deepseek-flash, deepseek-chat, deepseek-reasoner/],
|
|
54
57
|
]) {
|
|
55
58
|
const calls = [], lines = [];
|
|
56
|
-
const rc = await main(argv, (l) => lines.push(l), { spawn: fakeSpawn(calls), env, home: home(), platform: 'linux' });
|
|
59
|
+
const rc = await main(argv, (l) => lines.push(l), { spawn: fakeSpawn(calls), live: async () => null, env, home: home(), platform: 'linux' });
|
|
57
60
|
assert.strictEqual(rc, 2, argv.join(' '));
|
|
58
61
|
assert.strictEqual(calls.length, 0, 'claude not started: ' + argv.join(' '));
|
|
59
62
|
assert.match(lines.join('\n'), re);
|
|
60
63
|
}
|
|
61
64
|
assert.throws(() => plan(['--provider', 'deepseek'], {}, home()), /no API key/);
|
|
62
65
|
});
|
|
66
|
+
|
|
67
|
+
test('live model list: a newer listed id is accepted and its context window drives auto-compact', async () => {
|
|
68
|
+
const calls = [], lines = [];
|
|
69
|
+
const live = async (k) => { assert.strictEqual(k, KEY); return { 'deepseek-v5-preview': 2097152, 'deepseek-v4-pro': 1048576 }; };
|
|
70
|
+
const rc = await main(['--provider', 'deepseek', '--model', 'deepseek-v5-preview'], (l) => lines.push(l), { spawn: fakeSpawn(calls), live, env: { DEEPSEEK_API_KEY: KEY }, home: home(), platform: 'linux' });
|
|
71
|
+
assert.strictEqual(rc, 0);
|
|
72
|
+
assert.strictEqual(calls[0].opts.env.ANTHROPIC_MODEL, 'deepseek-v5-preview');
|
|
73
|
+
assert.strictEqual(calls[0].opts.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS, '2097152');
|
|
74
|
+
assert.match(lines[0], /context 2097152 tokens/);
|
|
75
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// TASK_2429: bug-diagnosis A/B arm in dispatch (temp dirs only).
|
|
2
|
+
// Usage: node .formation/tools/__tests__/diagnose-bug.test.js
|
|
3
|
+
'use strict';
|
|
4
|
+
const { spawnSync } = require('child_process');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const D = require('../dispatch');
|
|
9
|
+
|
|
10
|
+
const ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
11
|
+
const SB = fs.mkdtempSync(path.join(os.tmpdir(), 't2429_'));
|
|
12
|
+
let pass = 0; let fail = 0;
|
|
13
|
+
const check = (name, cond, detail) => { console.log((cond ? 'PASS' : 'FAIL') + ' | ' + name + (cond ? '' : ' | ' + detail)); cond ? pass++ : fail++; };
|
|
14
|
+
|
|
15
|
+
// detection
|
|
16
|
+
const card = (o) => JSON.stringify(o);
|
|
17
|
+
check('tag bug', D.isBugCard(card({ title: 'x', tags: ['bug'] })), '');
|
|
18
|
+
check('tag Fix (case)', D.isBugCard(card({ title: 'x', tags: ['Fix'] })), '');
|
|
19
|
+
check('tag regression', D.isBugCard(card({ title: 'x', tags: ['ui', 'regression'] })), '');
|
|
20
|
+
check('type: bug', D.isBugCard(card({ title: 'x', type: 'bug' })), '');
|
|
21
|
+
check('title fix:', D.isBugCard(card({ title: 'Fix login crash' })), '');
|
|
22
|
+
check('title bug', D.isBugCard(card({ title: 'bug in stats' })), '');
|
|
23
|
+
check('title CJK 修正', D.isBugCard(card({ title: '修正登入錯誤' })), '');
|
|
24
|
+
check('title CJK 修', D.isBugCard(card({ title: '修 dashboard 閃退' })), '');
|
|
25
|
+
check('negative: feature', !D.isBugCard(card({ title: 'Add export', tags: ['feature'] })), '');
|
|
26
|
+
check('negative: fixture word', !D.isBugCard(card({ title: 'Fixture loader for tests' })), '');
|
|
27
|
+
check('negative: fix mid-title', !D.isBugCard(card({ title: 'Add quick fix menu' })), '');
|
|
28
|
+
check('negative: debug tag', !D.isBugCard(card({ title: 'x', tags: ['debugging'] })), '');
|
|
29
|
+
check('negative: null / garbage', !D.isBugCard(null) && !D.isBugCard('not json') && !D.isBugCard('{}'), '');
|
|
30
|
+
|
|
31
|
+
// arm assignment
|
|
32
|
+
check('arm stable for same id', D.diagnoseArmFor('TASK_2429') === D.diagnoseArmFor('TASK_2429'), '');
|
|
33
|
+
let withN = 0;
|
|
34
|
+
for (let i = 1000; i < 1200; i++) if (D.diagnoseArmFor('TASK_' + i) === 'with') withN++;
|
|
35
|
+
check('arm balanced over 200 ids (40-60%)', withN >= 80 && withN <= 120, 'with=' + withN);
|
|
36
|
+
check('override on/off', D.diagnoseArmFor('TASK_1', 'on') === 'with' && D.diagnoseArmFor('TASK_1', 'off') === 'without', '');
|
|
37
|
+
|
|
38
|
+
// stats record carries the arm
|
|
39
|
+
D.appendDispatchPromptStats({ taskId: 'TASK_7', promptText: 'p', formation: 'unit', model: 'm', projectPath: SB, diagnoseArm: 'with' });
|
|
40
|
+
D.appendDispatchPromptStats({ taskId: 'TASK_8', promptText: 'p', formation: 'unit', model: 'm', projectPath: SB });
|
|
41
|
+
const recs = fs.readFileSync(path.join(SB, '.kenaz', 'specialist', 'dispatch_prompt_stats.jsonl'), 'utf8').trim().split('\n').map((l) => JSON.parse(l));
|
|
42
|
+
check('stats record: diagnose_arm = with / null for non-bug', recs[0].diagnose_arm === 'with' && recs[1].diagnose_arm === null, JSON.stringify(recs));
|
|
43
|
+
|
|
44
|
+
// real dry-run dispatch path in a sandbox project
|
|
45
|
+
for (const d of ['backlog', 'active', 'done', 'blocked', 'cancel']) fs.mkdirSync(path.join(SB, '.kenaz', 'tasks', d), { recursive: true });
|
|
46
|
+
fs.mkdirSync(path.join(SB, '.kenaz', 'locks'), { recursive: true });
|
|
47
|
+
const env = { ...process.env }; delete env.CLAUDE_PROJECT_DIR;
|
|
48
|
+
const run = (script, args) => spawnSync(process.execPath, [script, ...args], { encoding: 'utf8', env, windowsHide: true, cwd: ROOT });
|
|
49
|
+
const mk = (title, extra) => {
|
|
50
|
+
const r = run(path.join(ROOT, '.collaboration/core/task-api.js'), ['--project-path', SB, 'create', '--title', title, '--description', 'd', '--scope-paths', 'zzz/a.js',
|
|
51
|
+
'--requirements', 'r1', '--acceptance-criteria', 'a1', '--acceptance-criteria', 'a2', '--acceptance-criteria', 'a3', '--technical-notes', 't1', '--technical-notes', 't2', '--force', ...extra]);
|
|
52
|
+
return ((r.stdout || '').match(/TASK_\d+/) || [])[0];
|
|
53
|
+
};
|
|
54
|
+
const dry = (id, extra) => {
|
|
55
|
+
const r = run(path.join(ROOT, '.formation/tools/dispatch.js'), ['--skill=do', '--task=' + id, '--dispatch-mode=subagent', '--dry-run', '--project-path', SB, ...extra]);
|
|
56
|
+
try { return JSON.parse(r.stdout); } catch { return { err: (r.stdout + r.stderr).slice(-300) }; }
|
|
57
|
+
};
|
|
58
|
+
const bug = mk('Fix flaky stats crash', ['--tags', 'bug']);
|
|
59
|
+
const feat = mk('Add export button', ['--tags', 'feature']);
|
|
60
|
+
check('setup: cards created', !!bug && !!feat, bug + ' ' + feat);
|
|
61
|
+
if (bug && feat) {
|
|
62
|
+
const on = dry(bug, ['--diagnose', 'on']); const off = dry(bug, ['--diagnose', 'off']);
|
|
63
|
+
check('with arm: section + diagnoseArm', !!on.promptText && on.promptText.includes('## Bug diagnosis') && on.promptText.includes('diagnose-bug.md') && on.diagnoseArm === 'with', JSON.stringify(on).slice(0, 300));
|
|
64
|
+
check('without arm: no section, diagnoseArm=without', !!off.promptText && !off.promptText.includes('## Bug diagnosis') && off.diagnoseArm === 'without', JSON.stringify(off).slice(0, 300));
|
|
65
|
+
check('default arm follows the id hash', dry(bug, []).diagnoseArm === D.diagnoseArmFor(bug), '');
|
|
66
|
+
const fOn = dry(feat, ['--diagnose', 'on']); const fOff = dry(feat, ['--diagnose', 'off']);
|
|
67
|
+
check('non-bug: no section, no arm, prompt identical whatever --diagnose says', !!fOn.promptText && !fOn.promptText.includes('## Bug diagnosis') && fOn.diagnoseArm === undefined && fOn.promptText === fOff.promptText, JSON.stringify(fOn).slice(0, 300));
|
|
68
|
+
check('with-arm prompt = without-arm prompt + section only', !!on.promptText && !!off.promptText && on.promptText === off.promptText + '\n\n' + D.BUG_DIAGNOSIS_SECTION + '\n', '');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try { fs.rmSync(SB, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
72
|
+
console.log(`\n${pass} passed, ${fail} failed`);
|
|
73
|
+
process.exit(fail ? 1 : 0);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Diagnosing a bug
|
|
2
|
+
|
|
3
|
+
Adapted from `diagnosing-bugs` in github.com/mattpocock/skills (MIT license). Applies to bug cards in the
|
|
4
|
+
`with` arm of the TASK_2429 A/B (see dev/research/self-improvement/DESIGN.md).
|
|
5
|
+
|
|
6
|
+
The loop comes first. Guessing at a fix without a loop that fails on this bug is the main cause of wrong fixes.
|
|
7
|
+
|
|
8
|
+
1. **Red loop before any code change.** Build the cheapest thing that fails because of this bug: a test at the
|
|
9
|
+
nearest seam (the lowest function that still shows the wrong result), a CLI or HTTP repro script, a replayed
|
|
10
|
+
artifact (the log, payload or file that triggered it), or a bisect harness over commits. Run it and see it fail
|
|
11
|
+
for the reported reason, not for a missing fixture. Inline fixtures that differ from the real input are the usual
|
|
12
|
+
way a loop passes while the bug lives on: use the real path.
|
|
13
|
+
2. **Tighten it.** Fast (seconds), specific (fails only on this bug), deterministic (no clock, network or order
|
|
14
|
+
dependence; run it 3 times). A flaky loop proves nothing.
|
|
15
|
+
3. **Hypotheses, ranked.** Write 3 or fewer causes, most likely first. For each, name the observation that would
|
|
16
|
+
kill it (a value, a log line, a branch not taken). Instrument to get that observation; do not edit behavior to
|
|
17
|
+
test a guess. When a hypothesis dies, list the rest again instead of tweaking the same spot.
|
|
18
|
+
4. **Fix** the cause the observation shows, at the place it originates, not where the symptom appears.
|
|
19
|
+
5. **Prove it.** Loop green with the fix, and red again with the fix reverted (the control: green alone could
|
|
20
|
+
mean the loop never exercised the bug). Keep the test. Check one sibling: the same pattern elsewhere.
|
|
21
|
+
6. **Redact secrets** (tokens, keys, cookies, personal data) in anything you print, log, commit or report.
|
|
22
|
+
|
|
23
|
+
Report: the loop command, its red output (one line), the green output, and the revert check.
|
|
@@ -627,6 +627,38 @@ function buildCortexContext(scopePaths, projectPath) {
|
|
|
627
627
|
// TASK_1998 R1: dispatch-prompt telemetry
|
|
628
628
|
// ─────────────────────────────────────────
|
|
629
629
|
|
|
630
|
+
// TASK_2429: bug-diagnosis discipline (adapted from mattpocock/skills diagnosing-bugs, MIT) with a
|
|
631
|
+
// randomized A/B arm per bug card, so its benefit is measured (dev/research/self-improvement/DESIGN.md).
|
|
632
|
+
function isBugCard(taskData) {
|
|
633
|
+
let t;
|
|
634
|
+
try { t = typeof taskData === 'string' ? JSON.parse(taskData) : taskData; } catch { return false; }
|
|
635
|
+
if (!t || typeof t !== 'object') return false;
|
|
636
|
+
const tags = Array.isArray(t.tags) ? t.tags : (typeof t.tags === 'string' ? t.tags.split(/[,\s]+/) : []);
|
|
637
|
+
if (tags.some((x) => /^(bug|fix|regression)$/i.test(String(x).trim()))) return true;
|
|
638
|
+
if (/^bug$/i.test(String(t.type || '').trim())) return true;
|
|
639
|
+
return /^\s*((fix(es|ed)?|bug)\b|修)/i.test(String(t.title || ''));
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** 'with' | 'without': stable 50/50 split by task id (FNV-1a), unless forced by --diagnose on|off. */
|
|
643
|
+
function diagnoseArmFor(taskId, override) {
|
|
644
|
+
if (override === 'on') return 'with';
|
|
645
|
+
if (override === 'off') return 'without';
|
|
646
|
+
let h = 2166136261;
|
|
647
|
+
for (const ch of String(taskId || '')) { h ^= ch.charCodeAt(0); h = Math.imul(h, 16777619) >>> 0; }
|
|
648
|
+
return (h >>> 7) % 2 === 0 ? 'with' : 'without';
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const BUG_DIAGNOSIS_SECTION = [
|
|
652
|
+
'## Bug diagnosis',
|
|
653
|
+
'Follow .formation/tools/dispatch-detail/diagnose-bug.md (Read it first):',
|
|
654
|
+
'1. Build a loop that goes red on this bug before touching code (test at the nearest seam, repro script, replayed artifact).',
|
|
655
|
+
'2. Tighten it: fast, specific, deterministic.',
|
|
656
|
+
'3. Rank hypotheses, each with the observation that would kill it.',
|
|
657
|
+
'4. Fix the cause the loop shows.',
|
|
658
|
+
'5. Prove it: loop green with the fix, red with the fix reverted; keep the test.',
|
|
659
|
+
'6. Redact secrets in anything you print or commit.',
|
|
660
|
+
].join('\n');
|
|
661
|
+
|
|
630
662
|
/**
|
|
631
663
|
* Append one structured JSONL line to .kenaz/specialist/dispatch_prompt_stats.jsonl —
|
|
632
664
|
* one per sub-agent dispatch (getSubagentDispatchParams). Mirrors
|
|
@@ -641,7 +673,7 @@ function buildCortexContext(scopePaths, projectPath) {
|
|
|
641
673
|
* complexity (TASK_1998 two-axis policy): the normalized 'simple'|'medium'|'complex'
|
|
642
674
|
* CONTEXT-volume key (extractComplexity() default). Optional for older readers.
|
|
643
675
|
*/
|
|
644
|
-
function appendDispatchPromptStats({ taskId, rigor, complexity, formation, promptText, model, modelSource, projectPath }) {
|
|
676
|
+
function appendDispatchPromptStats({ taskId, rigor, complexity, formation, promptText, model, modelSource, projectPath, diagnoseArm }) {
|
|
645
677
|
const root = projectPath || ROOT;
|
|
646
678
|
const dir = path.join(root, '.kenaz', 'specialist');
|
|
647
679
|
try {
|
|
@@ -656,6 +688,7 @@ function appendDispatchPromptStats({ taskId, rigor, complexity, formation, promp
|
|
|
656
688
|
sections: splitPromptSections(promptText || ''),
|
|
657
689
|
model,
|
|
658
690
|
model_source: modelSource || null,
|
|
691
|
+
diagnose_arm: diagnoseArm || null,
|
|
659
692
|
});
|
|
660
693
|
fs.appendFileSync(path.join(dir, 'dispatch_prompt_stats.jsonl'), line + '\n', 'utf8');
|
|
661
694
|
} catch { /* best-effort — telemetry must never block dispatch */ }
|
|
@@ -743,6 +776,7 @@ Options:
|
|
|
743
776
|
--formation <name> Skill folder: unit (default) or solo
|
|
744
777
|
--skill <name> Skill to use (YAML filename without extension)
|
|
745
778
|
--task <value> Task ID (TASK_XXX) or free-text description
|
|
779
|
+
--diagnose on|off Force the bug-diagnosis A/B arm for a bug card (default: stable hash of the task id)
|
|
746
780
|
--dry-run Build and print the payload with no side effects
|
|
747
781
|
--dispatch-mode <mode> Required: subagent (JSON params for the Agent tool)
|
|
748
782
|
--harness <name> claude (default) | codex (Codex spawn_agent payload)
|
|
@@ -868,7 +902,7 @@ Sub-agent dispatch (Ansuz billing-aware path):
|
|
|
868
902
|
console.error('❌ --dispatch-mode=subagent is required (Formation headless/squad/legion dispatch is retired)');
|
|
869
903
|
process.exit(1);
|
|
870
904
|
}
|
|
871
|
-
getSubagentDispatchParams({ formation, skill, task, projectPath, force, useWorktree: useWorktreeCli, dispatchModelOverride: dispatchModelCli, dryRun, harness: harnessCli }).then(result => {
|
|
905
|
+
getSubagentDispatchParams({ formation, skill, task, projectPath, force, useWorktree: useWorktreeCli, dispatchModelOverride: dispatchModelCli, dryRun, harness: harnessCli, diagnose: getArg('--diagnose') }).then(result => {
|
|
872
906
|
if (!result) { process.exitCode = 1; return; }
|
|
873
907
|
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
874
908
|
}).catch(e => {
|
|
@@ -942,6 +976,7 @@ async function getSubagentDispatchParams(options) {
|
|
|
942
976
|
dryRun = false,
|
|
943
977
|
// TASK_2315: 'claude' (Agent tool) | 'codex' (Codex-native spawn_agent). Selects the payload shape.
|
|
944
978
|
harness = 'claude',
|
|
979
|
+
diagnose = null, // TASK_2429: --diagnose on|off forces the bug-diagnosis arm
|
|
945
980
|
} = options;
|
|
946
981
|
if (harness !== 'claude' && harness !== 'codex') {
|
|
947
982
|
console.error(`❌ --harness expects claude|codex (got '${harness}')`);
|
|
@@ -1190,7 +1225,10 @@ async function getSubagentDispatchParams(options) {
|
|
|
1190
1225
|
if (projectPath) promptOptions.projectPath = projectPath;
|
|
1191
1226
|
if (taskInput && taskInput.startsWith('TASK_')) promptOptions.taskId = taskInput;
|
|
1192
1227
|
|
|
1193
|
-
|
|
1228
|
+
let rawPrompt = buildPrompt(skillDef, taskInput, taskData, promptOptions);
|
|
1229
|
+
// TASK_2429: only bug cards get an arm; any other prompt stays byte-for-byte unchanged.
|
|
1230
|
+
const diagnoseArm = isBugCard(taskData) ? diagnoseArmFor(taskInput, diagnose) : null;
|
|
1231
|
+
if (diagnoseArm === 'with' && typeof rawPrompt === 'string') rawPrompt = rawPrompt + '\n\n' + BUG_DIAGNOSIS_SECTION + '\n';
|
|
1194
1232
|
|
|
1195
1233
|
// 4. Cortex context (same as Formation dispatch)
|
|
1196
1234
|
const taskScopePaths = (() => {
|
|
@@ -1335,7 +1373,7 @@ async function getSubagentDispatchParams(options) {
|
|
|
1335
1373
|
// by dashboard vs dashboard-by-rigor vs cli without re-deriving it.
|
|
1336
1374
|
// Two-axis policy: complexity rides along too, so dispatch-stats.js can
|
|
1337
1375
|
// group by (rigor, complexity) instead of rigor alone.
|
|
1338
|
-
if (!dryRun) appendDispatchPromptStats({ taskId: taskInput, rigor: resolution.rigorKey, complexity, formation, promptText, model: dispatchModel || rawModel, modelSource: resolution.source, projectPath });
|
|
1376
|
+
if (!dryRun) appendDispatchPromptStats({ taskId: taskInput, rigor: resolution.rigorKey, complexity, formation, promptText, model: dispatchModel || rawModel, modelSource: resolution.source, projectPath, diagnoseArm });
|
|
1339
1377
|
|
|
1340
1378
|
// TASK_2032: mirror headless.js's formation-path auto-activation (~L272-290)
|
|
1341
1379
|
// on the subagent path — every gate/guard above has passed and this function
|
|
@@ -1395,6 +1433,7 @@ async function getSubagentDispatchParams(options) {
|
|
|
1395
1433
|
promptText, taskId: taskInput, formation, skill, dispatchMode, effectiveMode: 'subagent',
|
|
1396
1434
|
...(isCodex ? { harness: 'codex', cwd: crossWorktree ? crossWorktree.worktreePath : null, worktreePath: crossWorktree ? crossWorktree.worktreePath : null } : {}),
|
|
1397
1435
|
modelTranslation, isolation, ...(agentParams ? { agentParams } : {}), ...(executor ? { executor } : {}), rigor: resolution.rigorKey, complexity, modelSource: resolution.source,
|
|
1436
|
+
...(diagnoseArm ? { diagnoseArm } : {}),
|
|
1398
1437
|
...(dryRun ? { dryRun: true } : {}),
|
|
1399
1438
|
};
|
|
1400
1439
|
}
|
|
@@ -1445,4 +1484,4 @@ function setupSubagentCrossProjectWorktree(projectPath, taskInput) {
|
|
|
1445
1484
|
};
|
|
1446
1485
|
}
|
|
1447
1486
|
|
|
1448
|
-
module.exports = { resolveDispatchModelForRigor, translateModelForAgentTool, DEFAULT_DISPATCH_MODEL, getSubagentDispatchParams, checkDispatchScopeGate, releaseDispatchLock, resolveDispatchIsolation, _getProvenSessionId, _getOwnConversationId, _isOwnClaimMarker, _buildCortexContext: buildCortexContext };
|
|
1487
|
+
module.exports = { isBugCard, diagnoseArmFor, BUG_DIAGNOSIS_SECTION, appendDispatchPromptStats, resolveDispatchModelForRigor, translateModelForAgentTool, DEFAULT_DISPATCH_MODEL, getSubagentDispatchParams, checkDispatchScopeGate, releaseDispatchLock, resolveDispatchIsolation, _getProvenSessionId, _getOwnConversationId, _isOwnClaimMarker, _buildCortexContext: buildCortexContext };
|
|
@@ -209,6 +209,37 @@ function lessonStats({ home, cwd, now, days }) {
|
|
|
209
209
|
} catch (e) { return { source: src, note: `${NO_DATA} (${e.message})` }; }
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
/** TASK_2429: bug cards per diagnosis arm (dispatch_prompt_stats.jsonl diagnose_arm) joined with the git first-pass verdicts of dispatchStats. */
|
|
213
|
+
function diagnoseAbStats({ cwd, now, days, dispatch }) {
|
|
214
|
+
const arms = {}; // card number -> arm (latest dispatch record wins)
|
|
215
|
+
try {
|
|
216
|
+
for (const l of fs.readFileSync(path.join(cwd, '.kenaz', 'specialist', 'dispatch_prompt_stats.jsonl'), 'utf8').split('\n')) {
|
|
217
|
+
let r; try { r = JSON.parse(l); } catch (_) { continue; }
|
|
218
|
+
const m = r && r.diagnose_arm && /^TASK_(\d+)$/i.exec(r.task_id || '');
|
|
219
|
+
if (m && (ms(r.ts) || 0) >= now - days * DAY) arms[m[1]] = r.diagnose_arm;
|
|
220
|
+
}
|
|
221
|
+
} catch (_) { /* no records */ }
|
|
222
|
+
const rework = new Set(((dispatch && dispatch.rework) || []).map((x) => x.card));
|
|
223
|
+
const merged = new Set((dispatch && dispatch.mergedCards) || []);
|
|
224
|
+
const out = {};
|
|
225
|
+
for (const arm of ['with', 'without']) {
|
|
226
|
+
const cards = Object.keys(arms).filter((n) => arms[n] === arm).map((n) => 'TASK_' + n);
|
|
227
|
+
const m = cards.filter((c) => merged.has(c));
|
|
228
|
+
out[arm] = { cards: cards.length, merged: m.length, firstPass: m.filter((c) => !rework.has(c)).length };
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** TASK_2428: glossary size/use, and first-pass of merged cards that got injected terms vs those that did not. */
|
|
234
|
+
function glossaryStats({ cwd, now, days, cur }) {
|
|
235
|
+
const g = require('../../.specialist/tools/glossary.js').stats(cwd, now, days);
|
|
236
|
+
const inj = new Set(g.cards);
|
|
237
|
+
const merged = cur.mergedCards || [];
|
|
238
|
+
const rework = new Set((cur.rework || []).map((r) => r.card));
|
|
239
|
+
const rate = (ids) => (ids.length ? { rate: ids.filter((c) => !rework.has(c)).length / ids.length, merged: ids.length } : null);
|
|
240
|
+
return { ...g, withTerms: rate(merged.filter((c) => inj.has(c))), withoutTerms: rate(merged.filter((c) => !inj.has(c))) };
|
|
241
|
+
}
|
|
242
|
+
|
|
212
243
|
function selfImprovementStats({ home, cwd, now, days }) {
|
|
213
244
|
const src = '<project>/.kenaz/specialist/correction-nudges.jsonl + lessons + .kenaz/knowledge/.harvested.json + git (this window vs the previous one)';
|
|
214
245
|
try {
|
|
@@ -216,7 +247,8 @@ function selfImprovementStats({ home, cwd, now, days }) {
|
|
|
216
247
|
const prev = dispatchStats({ repo: cwd, now: now - days * DAY, days, until: now - days * DAY });
|
|
217
248
|
const pick = (d) => (d.firstPassRate == null ? null : { rate: d.firstPassRate, merged: d.merged });
|
|
218
249
|
return { source: src, ...require('../../.specialist/tools/lessons-harvest.js').selfStats({ projectPath: cwd, home, now, days }),
|
|
219
|
-
firstPass: { window: pick(cur), previous: pick(prev) } }
|
|
250
|
+
firstPass: { window: pick(cur), previous: pick(prev) }, diagnoseAb: diagnoseAbStats({ cwd, now, days, dispatch: cur }),
|
|
251
|
+
glossary: glossaryStats({ cwd, now, days, cur }) };
|
|
220
252
|
} catch (e) { return { source: src, note: `${NO_DATA} (${e.message})` }; }
|
|
221
253
|
}
|
|
222
254
|
|
|
@@ -282,6 +314,13 @@ function render(s) {
|
|
|
282
314
|
` lessons by source: ${Object.entries(si.bySource).map(([k, v]) => `${k}=${v.lessons} (fired ${v.fired}, recurred before first fire ${v.recurBefore}, after ${v.recurAfter})`).join('; ') || 'none'}`,
|
|
283
315
|
` graduated ${si.graduated}; harvest items open ${si.harvestOpen}, recorded ${si.harvestRecorded}`,
|
|
284
316
|
` first-pass merge rate: this window ${r(si.firstPass.window)}, previous window ${r(si.firstPass.previous)}`);
|
|
317
|
+
if (si.diagnoseAb) {
|
|
318
|
+
const a = (x) => `${x.cards} bug cards, first-pass ${x.firstPass}/${x.merged}${x.merged ? ` (${(100 * x.firstPass / x.merged).toFixed(0)}%)` : ''}`;
|
|
319
|
+
L.push(` Bug diagnosis A/B: with: ${a(si.diagnoseAb.with)}; without: ${a(si.diagnoseAb.without)}${Math.min(si.diagnoseAb.with.merged, si.diagnoseAb.without.merged) < 10 ? ' (below 10 merged per arm: keep collecting)' : ''}`);
|
|
320
|
+
}
|
|
321
|
+
const g = si.glossary;
|
|
322
|
+
if (g) L.push(` glossary: ${g.terms} terms, ${g.added} added in window; ${g.injections} injections in ${g.cards.length} cards`,
|
|
323
|
+
` first-pass merge rate, cards with injected terms ${r(g.withTerms)}, without ${r(g.withoutTerms)}`);
|
|
285
324
|
}
|
|
286
325
|
}
|
|
287
326
|
return L.join('\n');
|
|
@@ -297,4 +336,4 @@ function main(argv, out = console.log, opts = {}) {
|
|
|
297
336
|
}
|
|
298
337
|
|
|
299
338
|
if (require.main === module) process.exit(main(process.argv.slice(2)));
|
|
300
|
-
module.exports = { main, collect, peerStats, dispatchStats, git, fieldStats, alayaStats, render };
|
|
339
|
+
module.exports = { diagnoseAbStats, main, collect, peerStats, dispatchStats, git, fieldStats, alayaStats, render };
|
|
@@ -161,6 +161,23 @@ test('alaya: bija counts, activations, exported and seed imports in window; miss
|
|
|
161
161
|
assert.ok(S.alayaStats({ home: path.join(tmp, 'nohome'), now: NOW, days: 14 }).note.startsWith('no data'));
|
|
162
162
|
});
|
|
163
163
|
|
|
164
|
+
test('diagnose A/B: bug cards and first-pass per arm from fake records (TASK_2429)', () => {
|
|
165
|
+
const cwd = path.join(tmp, 'ab-proj');
|
|
166
|
+
const f = path.join(cwd, '.kenaz', 'specialist', 'dispatch_prompt_stats.jsonl');
|
|
167
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
168
|
+
const rec = (id, arm, ts = iso(1)) => JSON.stringify({ ts, task_id: id, diagnose_arm: arm });
|
|
169
|
+
fs.writeFileSync(f, [rec('TASK_1', 'with'), rec('TASK_2', 'with'), rec('TASK_3', 'without'), rec('TASK_4', 'without'), rec('TASK_5', 'without'),
|
|
170
|
+
rec('TASK_6', 'with', iso(60)), JSON.stringify({ ts: iso(1), task_id: 'TASK_9', diagnose_arm: null }), 'not json'].join('\n') + '\n');
|
|
171
|
+
const dispatch = { mergedCards: ['TASK_1', 'TASK_2', 'TASK_3', 'TASK_4', 'TASK_9'], rework: [{ card: 'TASK_2' }, { card: 'TASK_3' }] };
|
|
172
|
+
const r = S.diagnoseAbStats({ cwd, now: NOW, days: 14, dispatch });
|
|
173
|
+
assert.deepStrictEqual(r.with, { cards: 2, merged: 2, firstPass: 1 });
|
|
174
|
+
assert.deepStrictEqual(r.without, { cards: 3, merged: 2, firstPass: 1 });
|
|
175
|
+
assert.deepStrictEqual(S.diagnoseAbStats({ cwd: path.join(tmp, 'none'), now: NOW, days: 14, dispatch }).with, { cards: 0, merged: 0, firstPass: 0 });
|
|
176
|
+
const text = S.render({ days: 14, peer: { source: '-', note: 'no data' }, dispatch: { source: '-', note: 'no data' }, fieldReports: { source: '-', note: 'no data' }, alaya: { source: '-', note: 'no data' },
|
|
177
|
+
selfImprovement: { source: '-', nudges: 0, converted: 0, bySource: {}, graduated: 0, harvestOpen: 0, harvestRecorded: 0, firstPass: {}, diagnoseAb: r } });
|
|
178
|
+
assert.ok(text.includes('Bug diagnosis A/B: with: 2 bug cards, first-pass 1/2 (50%); without: 3 bug cards, first-pass 1/2 (50%) (below 10 merged per arm: keep collecting)'), text);
|
|
179
|
+
});
|
|
180
|
+
|
|
164
181
|
test('main: --json parses and all four sections print; bad --days exits 2', () => {
|
|
165
182
|
const lines = [];
|
|
166
183
|
assert.strictEqual(S.main(['--json'], (m) => lines.push(m), { home: path.join(tmp, 'home2'), cwd: path.join(tmp, 'repo'), now: NOW }), 0);
|
|
@@ -170,6 +170,9 @@ function buildSubagentPrompt(skillDef, taskInput, taskData, options = {}) {
|
|
|
170
170
|
// TASK_2409: lessons the user's Ansuz learned for this project (bounded; nothing is added when there are none).
|
|
171
171
|
const learned = projectLessons(taskData, options);
|
|
172
172
|
if (learned.facts.length) lines.push('## Project lessons', ...learned.facts, '');
|
|
173
|
+
// TASK_2428: project glossary entries for the terms this card uses (nothing is added when none occur).
|
|
174
|
+
const terms = projectTerms(taskData, options);
|
|
175
|
+
if (terms.length) lines.push('## Project terms', ...terms, '');
|
|
173
176
|
|
|
174
177
|
lines.push('## Verify and report');
|
|
175
178
|
// Ponytail A/B 2026-10-10: the arm without this line broke an existing fixture test it never ran.
|
|
@@ -190,15 +193,33 @@ function buildSubagentPrompt(skillDef, taskInput, taskData, options = {}) {
|
|
|
190
193
|
return lines.join('\n');
|
|
191
194
|
}
|
|
192
195
|
|
|
196
|
+
// Same-project dispatch passes no --project-path (only cross-project does), so the project whose lessons and
|
|
197
|
+
// glossary apply is the Kenaz project around the cwd. Before this fallback, a plain dispatch injected neither
|
|
198
|
+
// (measured 2026-10-12: 0 checks without --project-path, 3 with it, same card).
|
|
199
|
+
function lessonProject(options) {
|
|
200
|
+
if (options.projectPath) return options.projectPath;
|
|
201
|
+
try { return require('../../.claude/hooks/_project-guard').findProjectRoot(process.cwd()); } catch { return null; }
|
|
202
|
+
}
|
|
203
|
+
|
|
193
204
|
function projectLessons(taskData, options) {
|
|
194
205
|
const none = { facts: [], checks: [] };
|
|
195
|
-
|
|
206
|
+
const projectPath = lessonProject(options);
|
|
207
|
+
if (!projectPath) return none;
|
|
196
208
|
try {
|
|
197
209
|
const parsed = typeof taskData === 'string' ? JSON.parse(taskData) : taskData;
|
|
198
|
-
return require('../../.specialist/tools/lessons').dispatchLessons(
|
|
210
|
+
return require('../../.specialist/tools/lessons').dispatchLessons(projectPath, (parsed && parsed.scope_paths) || []);
|
|
199
211
|
} catch { return none; }
|
|
200
212
|
}
|
|
201
213
|
|
|
214
|
+
function projectTerms(taskData, options) {
|
|
215
|
+
const projectPath = lessonProject(options);
|
|
216
|
+
if (!projectPath) return [];
|
|
217
|
+
try {
|
|
218
|
+
const parsed = typeof taskData === 'string' ? JSON.parse(taskData) : taskData;
|
|
219
|
+
return require('../../.specialist/tools/glossary').dispatchTerms(projectPath, parsed || {}).lines;
|
|
220
|
+
} catch { return []; }
|
|
221
|
+
}
|
|
222
|
+
|
|
202
223
|
function extractTaskId(taskData) {
|
|
203
224
|
try {
|
|
204
225
|
const parsed = typeof taskData === 'string' ? JSON.parse(taskData) : taskData;
|
|
@@ -10,6 +10,7 @@ const test = require('node:test');
|
|
|
10
10
|
const assert = require('node:assert/strict');
|
|
11
11
|
const fs = require('fs');
|
|
12
12
|
const path = require('path');
|
|
13
|
+
const os = require('os');
|
|
13
14
|
|
|
14
15
|
const {
|
|
15
16
|
buildPrompt,
|
|
@@ -310,7 +311,14 @@ test('TASK_2391: subagent prompt keeps card + commit rule + report, drops boiler
|
|
|
310
311
|
requirements: ['REQ-ONE'], acceptance_criteria: ['AC-ONE'], technical_notes: ['NOTE-ONE'],
|
|
311
312
|
complexity: 'complex', rigor: 'high',
|
|
312
313
|
};
|
|
313
|
-
|
|
314
|
+
// Built outside any Kenaz project: this measures the template, not the repo's own lessons/glossary
|
|
315
|
+
// (a dispatch without --project-path injects the cwd project's, see lessonProject in prompt.js).
|
|
316
|
+
const was = process.cwd();
|
|
317
|
+
let body;
|
|
318
|
+
try {
|
|
319
|
+
process.chdir(fs.mkdtempSync(path.join(os.tmpdir(), 'p2391-')));
|
|
320
|
+
body = buildPrompt(loadDoSkill(), 'TASK_9001', JSON.stringify(card), { isSubagent: true, taskId: 'TASK_9001' });
|
|
321
|
+
} finally { process.chdir(was); }
|
|
314
322
|
for (const must of ['REQ-ONE', 'AC-ONE', 'NOTE-ONE', 'src/a.js', '--no-verify -m "[TASK_9001] task completed"', '<= 20 lines', 'commit sha', 'did not verify']) {
|
|
315
323
|
assert.ok(body.includes(must), `missing: ${must}`);
|
|
316
324
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Glossary
|
|
2
|
+
|
|
3
|
+
## Ansuz
|
|
4
|
+
Kenaz's orchestrator persona: understands the request, does small fixes itself, dispatches cards to subagents, verifies on the real path, merges.
|
|
5
|
+
|
|
6
|
+
## Yggdrasil
|
|
7
|
+
Kenaz's multi-node collaboration: a shared room (team key ygk_...) on the relay where nodes exchange peer mail and task replicas.
|
|
@@ -106,3 +106,6 @@ REA (github.com/morluto/rea, MCP server `rea`) is optional and user-installed; t
|
|
|
106
106
|
## Code navigation
|
|
107
107
|
Grep/Glob for text; kenaz_lsp_definition / kenaz_lsp_callers / kenaz_lsp_references for symbols.
|
|
108
108
|
The memory system (synapse/hippocampus/cortex/alaya) is frozen since 2026-10-10: do not call it; lessons go to the native memory files.
|
|
109
|
+
|
|
110
|
+
## Project glossary (TASK_2428)
|
|
111
|
+
When the user defines or corrects a project term, or a term is ambiguous and the user settles it, record it with `kenaz glossary add "<term>" --means "<meaning>" [--not <x>] [--see <ref>]` (on an npm install: through core-resolve.js, like lessons). Read `.kenaz/GLOSSARY.md` before asking the user what a term means. Dispatch prompts get only the entries whose term the card mentions; benefit is measured in `kenaz stats` and judged under the rule in dev/research/self-improvement/DESIGN.md.
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// TASK_2428: project glossary. Temp projects and temp homes only.
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { spawnSync } = require('child_process');
|
|
9
|
+
const g = require('../glossary');
|
|
10
|
+
const { buildPrompt } = require('../../../.formation/tools/prompt');
|
|
11
|
+
|
|
12
|
+
const ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
13
|
+
const proj = () => fs.mkdtempSync(path.join(os.tmpdir(), 'k2428-'));
|
|
14
|
+
const skill = { name: 'dev', formation: 'unit' };
|
|
15
|
+
const prompt = (p, card) => buildPrompt(skill, 'do it', JSON.stringify(card), { projectPath: p, isSubagent: true, taskId: card.id });
|
|
16
|
+
const usage = (p) => { try { return fs.readFileSync(path.join(p, '.kenaz', 'specialist', 'glossary-usage.jsonl'), 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)).filter((r) => r.terms); } catch { return []; } };
|
|
17
|
+
|
|
18
|
+
test('glossary: add / replace (case-insensitive) / list / rm, file created lazily', () => {
|
|
19
|
+
const p = proj();
|
|
20
|
+
assert.equal(fs.existsSync(path.join(p, '.kenaz', 'GLOSSARY.md')), false);
|
|
21
|
+
assert.equal(g.add(p, 'Lane A', 'the fast path', { not: 'lane B', see: 'ADR-3' }).ok, true);
|
|
22
|
+
assert.equal(g.add(p, 'lane a', 'the first lane').replaced, true);
|
|
23
|
+
const t = g.readTerms(p);
|
|
24
|
+
assert.equal(t.length, 1);
|
|
25
|
+
assert.equal(t[0].means[0], 'the first lane');
|
|
26
|
+
assert.equal(g.rm(p, 'LANE A').ok, true);
|
|
27
|
+
assert.equal(g.readTerms(p).length, 0);
|
|
28
|
+
assert.equal(g.rm(p, 'nope').ok, false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('glossary: 60-term cap refuses a new term but still replaces an existing one', () => {
|
|
32
|
+
const p = proj();
|
|
33
|
+
for (let i = 0; i < 60; i++) assert.equal(g.add(p, `T${i}`, 'm').ok, true);
|
|
34
|
+
const r = g.add(p, 'T60', 'm');
|
|
35
|
+
assert.equal(r.ok, false);
|
|
36
|
+
assert.match(r.message, /full \(60 terms\)/);
|
|
37
|
+
assert.equal(g.add(p, 't3', 'new').ok, true);
|
|
38
|
+
assert.equal(g.readTerms(p).length, 60);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('glossary: boot line only with terms', () => {
|
|
42
|
+
const p = proj();
|
|
43
|
+
const boot = () => spawnSync(process.execPath, [path.join(ROOT, '.specialist', 'tools', 'ansuz-boot-fast.js'), '--project-path=' + p, '--conversation-id=s2428', '--mcp-base-url=http://127.0.0.1:1'],
|
|
44
|
+
{ encoding: 'utf8', cwd: p, env: { ...process.env, CLAUDE_PROJECT_DIR: p }, timeout: 30000 }).stdout;
|
|
45
|
+
assert.doesNotMatch(boot(), /\[glossary\]/);
|
|
46
|
+
g.add(p, 'Prism', 'the renderer');
|
|
47
|
+
assert.match(boot(), /\[glossary\] 1 terms \(\.kenaz\/GLOSSARY\.md\)/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('dispatch: only terms the card mentions are injected; a card without terms gets no section; logged', () => {
|
|
51
|
+
const p = proj();
|
|
52
|
+
g.add(p, 'Prism', 'the renderer');
|
|
53
|
+
g.add(p, 'NGO', 'non-profit partner');
|
|
54
|
+
assert.doesNotMatch(prompt(p, { id: 'TASK_1', title: 'fix typo', description: 'plain' }), /## Project terms/);
|
|
55
|
+
assert.equal(usage(p).length, 0);
|
|
56
|
+
const out = prompt(p, { id: 'TASK_2', title: 'Update prism output', requirements: ['keep it fast'] });
|
|
57
|
+
assert.match(out, /## Project terms\n- Prism: the renderer/);
|
|
58
|
+
assert.doesNotMatch(out, /NGO: non-profit/);
|
|
59
|
+
assert.doesNotMatch(prompt(p, { id: 'TASK_3', title: 'prisms and ngos' }), /## Project terms/, 'whole word only');
|
|
60
|
+
const u = usage(p);
|
|
61
|
+
assert.equal(u.length, 1);
|
|
62
|
+
assert.deepEqual([u[0].task, u[0].terms], ['TASK_2', ['Prism']]);
|
|
63
|
+
assert.match(prompt(p, { id: 'TASK_4', title: 'x', acceptance_criteria: ['NGO flow works'] }), /NGO: non-profit/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('dispatch: at most 8 entries and 800 bytes; CJK term matches by substring', () => {
|
|
67
|
+
const p = proj();
|
|
68
|
+
const names = Array.from({ length: 12 }, (_, i) => `Term${i}`);
|
|
69
|
+
for (const n of names) g.add(p, n, 'short');
|
|
70
|
+
const r = g.dispatchTerms(p, { id: 'TASK_5', title: names.join(' ') });
|
|
71
|
+
assert.equal(r.terms.length, 8);
|
|
72
|
+
const q = proj();
|
|
73
|
+
for (const n of names) g.add(q, n, 'x'.repeat(200));
|
|
74
|
+
const r2 = g.dispatchTerms(q, { id: 'TASK_6', title: names.join(' ') });
|
|
75
|
+
assert.ok(Buffer.byteLength(r2.lines.join('\n')) <= 800);
|
|
76
|
+
assert.ok(r2.terms.length >= 1 && r2.terms.length < 8);
|
|
77
|
+
const c = proj();
|
|
78
|
+
g.add(c, '通道', '資料傳輸路徑');
|
|
79
|
+
assert.deepEqual(g.dispatchTerms(c, { id: 'TASK_7', description: '修正通道逾時' }).terms, ['通道']);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('stats: terms added, injections and cards in the window', () => {
|
|
83
|
+
const p = proj();
|
|
84
|
+
g.add(p, 'Prism', 'r');
|
|
85
|
+
g.dispatchTerms(p, { id: 'TASK_9', title: 'prism' });
|
|
86
|
+
const s = g.stats(p, Date.now() + 1000, 30);
|
|
87
|
+
assert.deepEqual([s.terms, s.added, s.injections, s.cards], [1, 1, 1, ['TASK_9']]);
|
|
88
|
+
assert.equal(g.stats(p, Date.now() + 1000 + 40 * 86400000, 30).injections, 0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('cli: kenaz glossary through packages/kenaz/lib/cli.js with a staged core', () => {
|
|
92
|
+
const home = proj();
|
|
93
|
+
const p = proj();
|
|
94
|
+
const tools = path.join(home, '.kenaz', 'core', '.specialist', 'tools');
|
|
95
|
+
fs.mkdirSync(tools, { recursive: true });
|
|
96
|
+
fs.copyFileSync(path.join(ROOT, '.specialist', 'tools', 'glossary.js'), path.join(tools, 'glossary.js'));
|
|
97
|
+
const cli = path.join(ROOT, 'packages', 'kenaz', 'bin', 'kenaz.js');
|
|
98
|
+
const run = (...a) => spawnSync(process.execPath, [cli, 'glossary', ...a, '--project-path', p], { encoding: 'utf8', env: { ...process.env, HOME: home, USERPROFILE: home } });
|
|
99
|
+
let r = run('add', 'Lane A', '--means', 'fast path', '--not', 'lane B');
|
|
100
|
+
assert.equal(r.status, 0, r.stdout + r.stderr);
|
|
101
|
+
r = run('list');
|
|
102
|
+
assert.match(r.stdout, /Lane A: fast path \(not lane B\)/);
|
|
103
|
+
assert.match(fs.readFileSync(path.join(p, '.kenaz', 'GLOSSARY.md'), 'utf8'), /## Lane A/);
|
|
104
|
+
assert.equal(run('rm', 'lane a').status, 0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('same-project dispatch (no projectPath) still injects terms: the Kenaz project around the cwd applies', () => {
|
|
108
|
+
const p = proj();
|
|
109
|
+
fs.mkdirSync(path.join(p, '.kenaz'), { recursive: true });
|
|
110
|
+
fs.writeFileSync(path.join(p, '.kenaz', 'project.yaml'), 'name: t\n');
|
|
111
|
+
g.add(p, 'Prism', 'the replacement network layer');
|
|
112
|
+
fs.mkdirSync(path.join(p, 'src'));
|
|
113
|
+
const was = process.cwd();
|
|
114
|
+
try {
|
|
115
|
+
process.chdir(path.join(p, 'src'));
|
|
116
|
+
const card = { id: 'TASK_9', title: 'Port the Prism handshake' };
|
|
117
|
+
const out = buildPrompt(skill, 'do it', JSON.stringify(card), { isSubagent: true, taskId: card.id });
|
|
118
|
+
assert.match(out, /## Project terms\n- Prism: the replacement network layer/);
|
|
119
|
+
} finally { process.chdir(was); }
|
|
120
|
+
// control: outside any Kenaz project nothing is injected
|
|
121
|
+
const bare = proj();
|
|
122
|
+
try {
|
|
123
|
+
process.chdir(bare);
|
|
124
|
+
assert.doesNotMatch(buildPrompt(skill, 'do it', JSON.stringify({ id: 'TASK_9', title: 'Port the Prism handshake' }), { isSubagent: true, taskId: 'TASK_9' }), /## Project terms/);
|
|
125
|
+
} finally { process.chdir(was); }
|
|
126
|
+
});
|
|
@@ -1027,6 +1027,7 @@ async function main() {
|
|
|
1027
1027
|
if (landedLine) lines.push(landedLine);
|
|
1028
1028
|
try { const rea = require('./rea-detect'); const l = rea.reaBannerLine(rea.detectRea({ projectPath: BASE })); if (l) lines.push(l); } catch { /* optional */ }
|
|
1029
1029
|
try { for (const l of require('./lessons').bannerLines(BASE)) lines.push(l); } catch { /* optional */ }
|
|
1030
|
+
try { for (const l of require('./glossary').bannerLines(BASE)) lines.push(l); } catch { /* optional */ }
|
|
1030
1031
|
const peerLines = await fetchPendingPeerLines(mcpBase, WISH_TIMEOUT_MS, IS_PINNED ? BASE : process.env.CLAUDE_PROJECT_DIR || process.cwd());
|
|
1031
1032
|
for (const l of peerLines) lines.push(l);
|
|
1032
1033
|
lines.push('(boot 完成:' + (findHandoffDocs(BASE).length ? '先讀 → 標記的 handoff 全文,再' : '') + '依 Open cards 提議程;小修正直接做,中型工作開卡派工)');
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/**
|
|
4
|
+
* Project glossary (TASK_2428): each project keeps its own terms so agents use its language
|
|
5
|
+
* instead of re-deriving it. Idea adapted from the domain-modeling skill of
|
|
6
|
+
* github.com/mattpocock/skills (MIT license, Matt Pocock).
|
|
7
|
+
*
|
|
8
|
+
* File: <project>/.kenaz/GLOSSARY.md (a root GLOSSARY.md is also read). Format per term:
|
|
9
|
+
* ## <Term>
|
|
10
|
+
* 1-3 lines of meaning
|
|
11
|
+
* Not: <confusable term>
|
|
12
|
+
* See: <ADR/link>
|
|
13
|
+
* Created lazily on the first term. Injected into dispatch prompts only for terms the card mentions.
|
|
14
|
+
* <project>/.kenaz/specialist/glossary-usage.jsonl: one line per injection {ts, task, terms}
|
|
15
|
+
* and per added term {ts, added} (read by kenaz stats).
|
|
16
|
+
*
|
|
17
|
+
* kenaz glossary add "<term>" --means "<meaning>" [--not <x>] [--see <ref>] [--project-path <dir>]
|
|
18
|
+
* kenaz glossary list | rm <term>
|
|
19
|
+
*/
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
|
|
23
|
+
const MAX_TERMS = 60;
|
|
24
|
+
const INJECT_MAX_ENTRIES = 8;
|
|
25
|
+
const INJECT_MAX_BYTES = 800;
|
|
26
|
+
|
|
27
|
+
const fileOf = (p) => path.join(p, '.kenaz', 'GLOSSARY.md');
|
|
28
|
+
const logOf = (p) => path.join(p, '.kenaz', 'specialist', 'glossary-usage.jsonl');
|
|
29
|
+
const key = (t) => String(t).trim().toLowerCase();
|
|
30
|
+
|
|
31
|
+
function parse(text) {
|
|
32
|
+
const terms = [];
|
|
33
|
+
let cur = null;
|
|
34
|
+
for (const line of String(text || '').split(/\r?\n/)) {
|
|
35
|
+
const h = /^##\s+(.+?)\s*$/.exec(line);
|
|
36
|
+
if (h) { cur = { term: h[1], means: [], not: '', see: '' }; terms.push(cur); continue; }
|
|
37
|
+
if (!cur) continue;
|
|
38
|
+
const n = /^Not:\s*(.*)$/.exec(line);
|
|
39
|
+
const s = /^See:\s*(.*)$/.exec(line);
|
|
40
|
+
if (n) cur.not = n[1].trim();
|
|
41
|
+
else if (s) cur.see = s[1].trim();
|
|
42
|
+
else if (line.trim()) cur.means.push(line.trim());
|
|
43
|
+
}
|
|
44
|
+
return terms;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function render(terms) {
|
|
48
|
+
return '# Glossary\n\n' + terms.map((t) => `## ${t.term}\n${t.means.join('\n')}\n${t.not ? `Not: ${t.not}\n` : ''}${t.see ? `See: ${t.see}\n` : ''}`).join('\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function log(projectPath, row) {
|
|
52
|
+
try {
|
|
53
|
+
fs.mkdirSync(path.dirname(logOf(projectPath)), { recursive: true });
|
|
54
|
+
fs.appendFileSync(logOf(projectPath), JSON.stringify(row) + '\n');
|
|
55
|
+
} catch { /* measurement only */ }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Terms from .kenaz/GLOSSARY.md first, then a root GLOSSARY.md (the .kenaz one wins on a duplicate term). */
|
|
59
|
+
function readTerms(projectPath) {
|
|
60
|
+
const out = [];
|
|
61
|
+
const seen = new Set();
|
|
62
|
+
for (const f of [fileOf(projectPath), path.join(projectPath, 'GLOSSARY.md')]) {
|
|
63
|
+
let txt = '';
|
|
64
|
+
try { txt = fs.readFileSync(f, 'utf8'); } catch { continue; }
|
|
65
|
+
for (const t of parse(txt)) if (!seen.has(key(t.term))) { seen.add(key(t.term)); out.push(t); }
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function add(projectPath, term, means, extra = {}) {
|
|
71
|
+
term = String(term || '').trim();
|
|
72
|
+
means = String(means || '').trim();
|
|
73
|
+
if (!term || !means) return { ok: false, message: 'usage: kenaz glossary add "<term>" --means "<meaning>" [--not <x>] [--see <ref>]' };
|
|
74
|
+
let own = [];
|
|
75
|
+
try { own = parse(fs.readFileSync(fileOf(projectPath), 'utf8')); } catch { /* created lazily */ }
|
|
76
|
+
const entry = { term, means: means.split(/\r?\n/).slice(0, 3), not: extra.not || '', see: extra.see || '' };
|
|
77
|
+
const i = own.findIndex((t) => key(t.term) === key(term));
|
|
78
|
+
if (i < 0 && own.length >= MAX_TERMS) return { ok: false, message: `glossary is full (${MAX_TERMS} terms): remove one with \`kenaz glossary rm <term>\` first` };
|
|
79
|
+
if (i >= 0) own[i] = entry; else own.push(entry);
|
|
80
|
+
fs.mkdirSync(path.dirname(fileOf(projectPath)), { recursive: true });
|
|
81
|
+
fs.writeFileSync(fileOf(projectPath), render(own));
|
|
82
|
+
if (i < 0) log(projectPath, { ts: new Date().toISOString(), added: term });
|
|
83
|
+
return { ok: true, replaced: i >= 0, count: own.length };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function rm(projectPath, term) {
|
|
87
|
+
let own = [];
|
|
88
|
+
try { own = parse(fs.readFileSync(fileOf(projectPath), 'utf8')); } catch { return { ok: false }; }
|
|
89
|
+
const left = own.filter((t) => key(t.term) !== key(term));
|
|
90
|
+
if (left.length === own.length) return { ok: false };
|
|
91
|
+
fs.writeFileSync(fileOf(projectPath), render(left));
|
|
92
|
+
return { ok: true };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const CJK = /[ -鿿가--]/;
|
|
96
|
+
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
97
|
+
|
|
98
|
+
/** Whole word, case-insensitive; CJK terms by substring (no word boundaries in CJK text). */
|
|
99
|
+
function mentions(text, term) {
|
|
100
|
+
if (CJK.test(term)) return text.toLowerCase().includes(term.toLowerCase());
|
|
101
|
+
return new RegExp(`(^|[^\\p{L}\\p{N}_])${esc(term)}(?![\\p{L}\\p{N}_])`, 'iu').test(text);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function cardText(card) {
|
|
105
|
+
const list = (v) => (Array.isArray(v) ? v.map((x) => (typeof x === 'string' ? x : JSON.stringify(x))).join('\n') : String(v || ''));
|
|
106
|
+
return [card.title, card.description, list(card.requirements), list(card.acceptance_criteria)].filter(Boolean).join('\n');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Entries (<= 8, <= 800 bytes) for the glossary terms that occur in the card; logs the injection. */
|
|
110
|
+
function dispatchTerms(projectPath, card, now = Date.now()) {
|
|
111
|
+
const none = { lines: [], terms: [] };
|
|
112
|
+
try {
|
|
113
|
+
const text = cardText(card || {});
|
|
114
|
+
const lines = [];
|
|
115
|
+
const used = [];
|
|
116
|
+
let bytes = 0;
|
|
117
|
+
for (const t of readTerms(projectPath)) {
|
|
118
|
+
if (used.length >= INJECT_MAX_ENTRIES || !mentions(text, t.term)) continue;
|
|
119
|
+
const line = `- ${t.term}: ${t.means.join(' ')}${t.not ? ` (not ${t.not})` : ''}${t.see ? ` [see ${t.see}]` : ''}`;
|
|
120
|
+
if (bytes + Buffer.byteLength(line) + 1 > INJECT_MAX_BYTES) break;
|
|
121
|
+
bytes += Buffer.byteLength(line) + 1;
|
|
122
|
+
lines.push(line);
|
|
123
|
+
used.push(t.term);
|
|
124
|
+
}
|
|
125
|
+
if (!used.length) return none;
|
|
126
|
+
log(projectPath, { ts: new Date(now).toISOString(), task: (card && card.id) || null, terms: used });
|
|
127
|
+
return { lines, terms: used };
|
|
128
|
+
} catch { return none; }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function bannerLines(projectPath) {
|
|
132
|
+
const n = readTerms(projectPath).length;
|
|
133
|
+
return n ? [`[glossary] ${n} terms (.kenaz/GLOSSARY.md)`] : [];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** For kenaz stats: terms now, terms added / injections / cards with injections inside the window. */
|
|
137
|
+
function stats(projectPath, now, days) {
|
|
138
|
+
const since = now - days * 86400000;
|
|
139
|
+
const rows = [];
|
|
140
|
+
try {
|
|
141
|
+
for (const l of fs.readFileSync(logOf(projectPath), 'utf8').split('\n')) {
|
|
142
|
+
if (!l.trim()) continue;
|
|
143
|
+
try { const r = JSON.parse(l); if (Date.parse(r.ts) >= since && Date.parse(r.ts) <= now) rows.push(r); } catch { /* skip bad line */ }
|
|
144
|
+
}
|
|
145
|
+
} catch { /* no log yet */ }
|
|
146
|
+
const inj = rows.filter((r) => r.terms);
|
|
147
|
+
return {
|
|
148
|
+
terms: readTerms(projectPath).length,
|
|
149
|
+
added: rows.filter((r) => r.added).length,
|
|
150
|
+
injections: inj.length,
|
|
151
|
+
cards: [...new Set(inj.map((r) => r.task).filter(Boolean))],
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function main(argv, out = console.log) {
|
|
156
|
+
const args = argv.slice();
|
|
157
|
+
const take = (flag) => { const i = args.indexOf(flag); if (i < 0) return undefined; const v = args[i + 1]; args.splice(i, 2); return v; };
|
|
158
|
+
const projectPath = path.resolve(take('--project-path') || process.cwd());
|
|
159
|
+
const means = take('--means');
|
|
160
|
+
const not = take('--not');
|
|
161
|
+
const see = take('--see');
|
|
162
|
+
const [cmd, ...rest] = args;
|
|
163
|
+
if (cmd === 'add') {
|
|
164
|
+
const r = add(projectPath, rest.join(' '), means, { not, see });
|
|
165
|
+
out(r.ok ? `${r.replaced ? 'replaced' : 'added'} "${rest.join(' ')}" (${r.count} terms)` : r.message);
|
|
166
|
+
return r.ok ? 0 : 1;
|
|
167
|
+
}
|
|
168
|
+
if (cmd === 'rm' && rest.length) {
|
|
169
|
+
const r = rm(projectPath, rest.join(' '));
|
|
170
|
+
out(r.ok ? `removed "${rest.join(' ')}"` : `no such term: ${rest.join(' ')}`);
|
|
171
|
+
return r.ok ? 0 : 1;
|
|
172
|
+
}
|
|
173
|
+
if (cmd === 'list') {
|
|
174
|
+
const t = readTerms(projectPath);
|
|
175
|
+
if (!t.length) out('no glossary terms');
|
|
176
|
+
else for (const x of t) out(`${x.term}: ${x.means.join(' ')}${x.not ? ` (not ${x.not})` : ''}${x.see ? ` [see ${x.see}]` : ''}`);
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
out('usage: kenaz glossary <add "<term>" --means "<meaning>" [--not <x>] [--see <ref>] | list | rm <term>> [--project-path <dir>]');
|
|
180
|
+
return 2;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (require.main === module) process.exit(main(process.argv.slice(2)));
|
|
184
|
+
module.exports = { main, add, rm, readTerms, dispatchTerms, bannerLines, stats, parse, mentions };
|
|
@@ -168,7 +168,11 @@ function openCount(projectPath, now = Date.now(), o = {}) {
|
|
|
168
168
|
try { c = JSON.parse(fs.readFileSync(cacheFile(projectPath), 'utf8')); } catch (_) { /* no cache */ }
|
|
169
169
|
if (c && now - c.t < CACHE_TTL_MS) return c.n;
|
|
170
170
|
if (o.sync) return writeCount(projectPath, now);
|
|
171
|
-
|
|
171
|
+
// No git, no history to scan: never start a background process (its git calls hold the folder open on Windows).
|
|
172
|
+
if (!fs.existsSync(path.join(root(projectPath), '.git'))) return c ? c.n : 0;
|
|
173
|
+
// cwd = home, not the project: on Windows a process whose cwd is the project locks that folder for the ~20 s
|
|
174
|
+
// the scan takes (no delete/rename; broke two boot tests' temp-dir cleanup with EPERM). The path is absolute.
|
|
175
|
+
(o.refresh || ((p) => require('child_process').spawn(process.execPath, [__filename, '--refresh-cache', path.resolve(p)], { cwd: require('os').homedir(), detached: true, stdio: 'ignore', windowsHide: true }).unref()))(projectPath);
|
|
172
176
|
return c ? c.n : 0;
|
|
173
177
|
}
|
|
174
178
|
|
package/core/KENAZ_CORE_VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.3.
|
|
1
|
+
0.3.15
|
|
@@ -1,52 +1,43 @@
|
|
|
1
1
|
## 專案路徑
|
|
2
|
-
ARGUMENTS 含 `[專案: X]` 或 `--project-path X` → PROJECT_PATH = X,下方 boot
|
|
2
|
+
ARGUMENTS 含 `[專案: X]` 或 `--project-path X` → PROJECT_PATH = X,下方 boot、task-api、dispatch 都附加 `--project-path "<PROJECT_PATH>"`。沒有就不加。
|
|
3
3
|
|
|
4
4
|
# Ansuz boot
|
|
5
5
|
|
|
6
|
-
你是 **Ansuz**,Kenaz 的 orchestrator
|
|
7
|
-
|
|
8
|
-
## Boot(一次 Bash
|
|
9
|
-
1. Turn Context
|
|
10
|
-
2.
|
|
11
|
-
3. banner 有 `📎 Project handoff
|
|
12
|
-
4.
|
|
13
|
-
|
|
14
|
-
##
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
-
|
|
26
|
-
- subagent 回報「未驗證」的項目,由你補驗或明講仍未驗證。
|
|
27
|
-
- 合併:`git merge` 後跑相關測試(或 `node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .specialist/Ansuz/tools/verify-and-land.js`),用這個專案自己的測試指令。
|
|
28
|
-
|
|
29
|
-
## 交接(handoff,ISBAR)
|
|
30
|
-
用戶說交接/暫停/換 session、context 快滿、或結束時仍有未完成的工作 → 寫交接:
|
|
31
|
-
1. `node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .specialist/tools/handoff-doc.js new --topic "<主題>" --session <id>`(有 PROJECT_PATH 加 `--project-path`),建立 `<專案>/.kenaz/handoffs/<日期時間>-<主題>.md`,I 段已自動填好。
|
|
32
|
-
2. 用 Edit 填 S(現況)、B(背景:用戶決定原話、相關卡與 commit)、A(已驗證/未驗證/風險)、R(下一個 session 第一步、待用戶決定的事)。寫給「沒看過這段對話的人」,不寫空泛句。
|
|
33
|
-
3. 一份交接一個檔,不覆寫舊的;下一個 session 的 boot 會自動列出並讀最新那份。
|
|
6
|
+
你是 **Ansuz**,Kenaz 的 orchestrator:理解需求,自己動手或拆卡派給 subagent,合併前在真實路徑上驗證,然後回報。先查現況、確認 scope,再動手。
|
|
7
|
+
|
|
8
|
+
## Boot(一次 Bash)
|
|
9
|
+
1. Turn Context 有 `[session] session_id=<id>` 就記住 `<id>`。
|
|
10
|
+
2. `node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .specialist/tools/ansuz-boot-fast.js`(有 `<id>` 加 `--conversation-id="<id>"`;有 PROJECT_PATH 加 `--project-path`)。
|
|
11
|
+
3. banner 有 `📎 Project handoff`:先 Read → 標記的那份全文,以它接續。
|
|
12
|
+
4. 轉述 banner,依 handoff 的 R 段與 `📋 Open cards` 提出議程。`[lessons]`、`[learn]`、`[glossary]` 行照 RULES.md 的 Lessons / Project glossary 處理。
|
|
13
|
+
|
|
14
|
+
## 工作方式
|
|
15
|
+
- **自己做**:約 30 行內的小修正 → 直接改,跑最近的測試,提交。
|
|
16
|
+
- **開卡派工**(中型、可平行、高風險):`kenaz_task_create` 開卡(requirements、acceptance criteria、scope paths)→ `node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .formation/tools/dispatch.js --skill=do --task=TASK_XXX --dispatch-mode=subagent` → `promptText` 給 subagent、`agentParams` 照用(不換模型);輸出是 `executor` 區塊時照 RULES.md 的 Dispatch 執行。subagent 在 worktree commit,你驗證後合併。
|
|
17
|
+
- 開卡前確認 scope 與驗收條件對準真正的問題。
|
|
18
|
+
|
|
19
|
+
## 驗證(合併前,強度跟風險相稱)
|
|
20
|
+
- **真實路徑**:實際跑使用者會走的路,不只看單元測試。
|
|
21
|
+
- **對照組**:證明測試在舊程式上會失敗。
|
|
22
|
+
- subagent 的「未驗證」由你補驗或明講。合併後跑相關測試(含被改檔案的既有測試)。
|
|
23
|
+
|
|
24
|
+
## 交接
|
|
25
|
+
用戶說交接/暫停、context 快滿、或結束時有未完成工作:`node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .specialist/tools/handoff-doc.js new --topic "<主題>" --session <id>`,再填 S/B/A/R(見 RULES.md 的 Handoff)。
|
|
34
26
|
|
|
35
27
|
## 回報與提問
|
|
36
|
-
-
|
|
37
|
-
- 需要用戶決定時用 `AskUserQuestion`(2-4
|
|
38
|
-
-
|
|
28
|
+
- 用用戶的語言(中文訊息 → 中文,含提問選項)。
|
|
29
|
+
- 需要用戶決定時用 `AskUserQuestion`(2-4 選項、建議的放第一),有證據能自己判斷的不問。
|
|
30
|
+
- 說清楚做了什麼、怎麼驗證、什麼沒驗證或要用戶決定。不說「應該可以」。
|
|
39
31
|
|
|
40
|
-
## Kenaz
|
|
41
|
-
|
|
32
|
+
## Kenaz 本身出問題:先回報再繞過
|
|
33
|
+
繞過之前先 `kenaz_wish_create`(`scope` kenaz|project;`wish_type` bug|feature|question;一行重現 + 預期 vs 實際 + `npx kenaz doctor` 版本 + session id,不貼金鑰),報完照常完成工作。
|
|
42
34
|
|
|
43
35
|
## 自主模式
|
|
44
|
-
|
|
36
|
+
用戶明確授權自主時:`node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .specialist/tools/autonomy.js grant --session <id> --scope "<範圍>"`,讀 RULES.md 的 Autonomy;只在必要停點 `pause`,做完 `done`。
|
|
45
37
|
|
|
46
|
-
##
|
|
47
|
-
-
|
|
48
|
-
-
|
|
49
|
-
-
|
|
50
|
-
- banner 出現 `[learn] N history items to review`:跑 `kenaz learn review`,每個歷史項目(返工、executor 失敗、blocked 卡、handoff 風險行)有現成的 `kenaz learn add ... --source harvest:<id>`,值得記的補上 why 後執行,其餘忽略;指標建議(各 rigor 一次過率、返工最多的檔案)只建議給用戶,不自行套用。詳見 RULES.md。
|
|
38
|
+
## 教訓、詞彙、參考產品
|
|
39
|
+
- 用戶糾正、推翻決定、或驗證抓到失敗:`node "${CLAUDE_PLUGIN_ROOT}/hooks/core-resolve.js" .specialist/tools/lessons.js add --scope user|project --type pref|fact|pitfall|check --text "..."`,回報點名一行。
|
|
40
|
+
- 用戶定義或拍板專案術語:`kenaz glossary add "<term>" --means "..."`;問術語前先讀 `.kenaz/GLOSSARY.md`。
|
|
41
|
+
- `[rea]` 行、教訓生命週期與歷程收割:見 RULES.md(REA、Lessons)。
|
|
51
42
|
|
|
52
|
-
|
|
43
|
+
需要更多規則時(自主、派工、合併、交接、教訓、REA)讀 `${CLAUDE_PLUGIN_ROOT}/../../.specialist/Ansuz/RULES.md`。
|
package/lib/cli.js
CHANGED
|
@@ -14,7 +14,7 @@ const dash = require('./dashboard');
|
|
|
14
14
|
const PKG_ROOT = path.resolve(__dirname, '..');
|
|
15
15
|
const PKG = require('../package.json');
|
|
16
16
|
const MIN_NODE = 18;
|
|
17
|
-
const USAGE = 'usage: kenaz <install|uninstall|status|doctor|init|ygg join <key>|ygg create [--name N]|ygg status|ygg leave|alaya status|alaya register|subagent [set <id>|unset] [--rigor r]|launch --provider deepseek [--model id]|stats [--days N] [--json]|learn <add|list|rm|review|merge|graduate>> [--dry-run] [--home <dir>] [--no-service] [--no-claude] [--no-codex] [--keep-dashboard] [--server-bin <path>]';
|
|
17
|
+
const USAGE = 'usage: kenaz <install|uninstall|status|doctor|init|ygg join <key>|ygg create [--name N]|ygg status|ygg leave|alaya status|alaya register|subagent [set <id>|unset] [--rigor r]|launch --provider deepseek [--model id]|stats [--days N] [--json]|learn <add|list|rm|review|merge|graduate>|glossary <add|list|rm>> [--dry-run] [--home <dir>] [--no-service] [--no-claude] [--no-codex] [--keep-dashboard] [--server-bin <path>]';
|
|
18
18
|
|
|
19
19
|
function parseArgs(argv) {
|
|
20
20
|
const o = { cmd: argv[0], flags: {} };
|
|
@@ -42,6 +42,7 @@ function coreModule(p, ...rel) {
|
|
|
42
42
|
}
|
|
43
43
|
const reaDetect = (p) => coreModule(p, '.specialist', 'tools', 'rea-detect.js');
|
|
44
44
|
const lessons = (p) => coreModule(p, '.specialist', 'tools', 'lessons.js');
|
|
45
|
+
const glossary = (p) => coreModule(p, '.specialist', 'tools', 'glossary.js');
|
|
45
46
|
const dispatchModel = (p) => coreModule(p, '.formation', 'tools', 'dispatch-model.js');
|
|
46
47
|
const providerLaunch = (p) => coreModule(p, '.formation', 'bifrost', 'provider-launch.js');
|
|
47
48
|
const kenazStats = (p) => coreModule(p, '.formation', 'tools', 'kenaz-stats.js');
|
|
@@ -356,6 +357,11 @@ function run(argv, out = console.log, deps = {}) {
|
|
|
356
357
|
if (!ks) { out('kenaz-core not installed: run `kenaz install`'); return 1; }
|
|
357
358
|
return ks.main(argv.slice(1), out);
|
|
358
359
|
}
|
|
360
|
+
if (argv[0] === 'glossary') {
|
|
361
|
+
const gl = glossary(paths(os.homedir()));
|
|
362
|
+
if (!gl) { out('kenaz-core not installed: run `kenaz install`'); return 1; }
|
|
363
|
+
return gl.main(argv.slice(1), out);
|
|
364
|
+
}
|
|
359
365
|
if (argv[0] === 'learn') {
|
|
360
366
|
const hi = argv.indexOf('--home');
|
|
361
367
|
const lm = lessons(paths(hi >= 0 && argv[hi + 1] ? path.resolve(argv[hi + 1]) : os.homedir()));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kenaz",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.15",
|
|
4
4
|
"description": "Install Kenaz (plugin, hooks, kenaz-server) for Claude Code and Codex: npx kenaz install",
|
|
5
5
|
"bin": {
|
|
6
6
|
"kenaz": "bin/kenaz.js"
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
"test": "node --test lib/cli.test.js"
|
|
21
21
|
},
|
|
22
22
|
"optionalDependencies": {
|
|
23
|
-
"@kenazai/server-darwin-arm64": "0.3.
|
|
24
|
-
"@kenazai/server-darwin-x64": "0.3.
|
|
25
|
-
"@kenazai/server-linux-arm64": "0.3.
|
|
26
|
-
"@kenazai/server-linux-x64": "0.3.
|
|
27
|
-
"@kenazai/server-win32-x64": "0.3.
|
|
23
|
+
"@kenazai/server-darwin-arm64": "0.3.15",
|
|
24
|
+
"@kenazai/server-darwin-x64": "0.3.15",
|
|
25
|
+
"@kenazai/server-linux-arm64": "0.3.15",
|
|
26
|
+
"@kenazai/server-linux-x64": "0.3.15",
|
|
27
|
+
"@kenazai/server-win32-x64": "0.3.15"
|
|
28
28
|
},
|
|
29
29
|
"license": "UNLICENSED",
|
|
30
30
|
"repository": {
|