kenaz 0.3.11 → 0.3.13

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.
Files changed (60) hide show
  1. package/core/.claude/commands/Ansuz.md +11 -1
  2. package/core/.claude/hooks/__tests__/correction-detect.test.js +74 -0
  3. package/core/.claude/hooks/_run-user-prompt.js +2 -0
  4. package/core/.claude/hooks/_run-user-prompt.test.js +2 -2
  5. package/core/.claude/hooks/correction-detect.js +99 -0
  6. package/core/.collaboration/core/task-2288-followups.test.js +2 -37
  7. package/core/.collaboration/core/task-2421-replica.test.js +229 -0
  8. package/core/.collaboration/core/task-api.js +221 -12
  9. package/core/.collaboration/core/task-replica.js +189 -0
  10. package/core/.formation/.merge-lock +1 -0
  11. package/core/.formation/bifrost/codex-doctor.js +12 -2
  12. package/core/.formation/bifrost/codex-doctor.test.js +15 -0
  13. package/core/.formation/bifrost/exec.js +147 -0
  14. package/core/.formation/bifrost/provider-launch.js +57 -0
  15. package/core/.formation/bifrost/provider-launch.test.js +62 -0
  16. package/core/.formation/bifrost/setup-codex.js +84 -16
  17. package/core/.formation/tools/__tests__/dispatch-model.test.js +1 -1
  18. package/core/.formation/tools/__tests__/subagent-executor.test.js +198 -0
  19. package/core/.formation/tools/dispatch-model.js +15 -10
  20. package/core/.formation/tools/dispatch.js +46 -10
  21. package/core/.formation/tools/kenaz-stats.js +57 -16
  22. package/core/.formation/tools/kenaz-stats.test.js +2 -2
  23. package/core/.formation/tools/post-complete/task2420.test.js +129 -0
  24. package/core/.formation/tools/post-complete.js +129 -8
  25. package/core/.kenaz/specialist/memory_guard_fired.json +1 -1
  26. package/core/.kenaz/specialist/memory_guard_trigger_cache.json +1 -1
  27. package/core/.specialist/Ansuz/RULES.md +7 -2
  28. package/core/.specialist/tools/__tests__/handoff-doc.test.js +46 -0
  29. package/core/.specialist/tools/__tests__/rea-detect.test.js +10 -0
  30. package/core/.specialist/tools/__tests__/task-2425-lessons-lifecycle.test.js +142 -0
  31. package/core/.specialist/tools/__tests__/task-2427-harvest.test.js +150 -0
  32. package/core/.specialist/tools/ansuz-boot-fast.js +37 -1
  33. package/core/.specialist/tools/handoff-doc.js +95 -0
  34. package/core/.specialist/tools/lessons-harvest.js +243 -0
  35. package/core/.specialist/tools/lessons-lifecycle.js +103 -0
  36. package/core/.specialist/tools/lessons.js +149 -24
  37. package/core/.specialist/tools/rea-detect.js +6 -2
  38. package/core/.synapse/memory-shared.json.sync +1 -0
  39. package/core/KENAZ_CORE_VERSION +1 -1
  40. package/core/dev/plans/CLI_FIRST_INSTALL_FLOW.md +75 -109
  41. package/core/dev/scripts/correction-detect-eval.js +70 -0
  42. package/core/dev/specs/peer/CODEX_MANAGED_DAEMON.md +54 -0
  43. package/core/dev/specs/peer/setup-codex-hooks.test.js +38 -0
  44. package/core/dev/spikes/task2145-evidence-20261004/backend-log-excerpt.txt +56 -0
  45. package/core/dev/spikes/task2145-evidence-20261004/graph.shadow +0 -0
  46. package/core/dev/spikes/task2145-evidence-20261004/graph.wal +0 -0
  47. package/core/dev/spikes/task2145-evidence-20261004/graph_corrupt_suspect.marker +1 -0
  48. package/core/plugins/kenaz/commands/Ansuz.md +11 -1
  49. package/core/plugins/kenaz/commands/kenaz.md +5 -3
  50. package/core/plugins/kenaz/scripts/kenaz-mode.js +6 -0
  51. package/core/plugins/kenaz/scripts/kenaz.test.js +68 -0
  52. package/core/plugins/kenaz/scripts/statusline.js +60 -0
  53. package/core/plugins/kenaz/scripts/statusline.test.js +41 -0
  54. package/core/plugins/kenaz/scripts/ygg.js +43 -4
  55. package/lib/cli.js +27 -10
  56. package/lib/cli.test.js +10 -0
  57. package/lib/dashboard.test.js +3 -3
  58. package/package.json +6 -6
  59. package/vendor/install-kenaz-cli.js +193 -16
  60. package/core/.collaboration/core/task-2264-single-after-capture.test.js +0 -61
@@ -8,7 +8,8 @@ ARGUMENTS 含 `[專案: X]` 或 `--project-path X` → PROJECT_PATH = X,下方
8
8
  ## Boot(一次 Bash,無其他儀式)
9
9
  1. Turn Context 若有 `[session] session_id=<id>`,記住 `<id>`。
10
10
  2. 執行 `node .specialist/tools/ansuz-boot-fast.js`(有 `<id>` 加 `--conversation-id="<id>"`;有 PROJECT_PATH 加 `--project-path`)。
11
- 3. 把 banner 轉述給用戶(Bash 輸出在 UI 會摺疊),並依 `📋 Open cards` 直接提出議程。
11
+ 3. banner 有 `📎 Project handoff` 時,**先用 Read 讀 → 標記的那份全文**(不用問用戶),以它為準接續上一個 session。
12
+ 4. 把 banner 轉述給用戶(Bash 輸出在 UI 會摺疊),依 handoff 的 R 段與 `📋 Open cards` 直接提出議程。
12
13
 
13
14
  ## 每件工作的做法
14
15
  - **自己做**:小而明確的修正(約 30 行內、單一檔案或少數檔案)→ 直接改,跑最近的測試,提交。
@@ -16,6 +17,7 @@ ARGUMENTS 含 `[專案: X]` 或 `--project-path X` → PROJECT_PATH = X,下方
16
17
  1. 用 `kenaz_task_create` 開卡(`projectPath` = 目前專案;寫清楚 requirements、acceptance criteria、scope paths)。查卡、改狀態用 `kenaz_task_list` / `kenaz_task_get` / `kenaz_task_update`。
17
18
  2. 產生派工內容:`node .formation/tools/dispatch.js --skill=do --task=TASK_XXX --dispatch-mode=subagent`(有 PROJECT_PATH 加 `--project-path`)。把輸出 JSON 的 `promptText` 交給 subagent,`agentParams` 照用(模型不要自己換)。
18
19
  3. subagent 在 worktree 裡改完並 commit;你驗證後合併。
20
+ 若輸出是 `executor` 區塊(模型為 `codex:<id>` / `deepseek:<model>`,無 agentParams):用 Bash run_in_background 執行 `executor.command`,等完成通知,讀 `executor.resultFile`(ok、commits;`no commits` = 失敗),再照常驗證並合併 `executor.branchName`。
19
21
  - 開卡前先確認 scope 與驗收條件對準真正的問題,不要照著錯的假設派工。
20
22
 
21
23
  ## 驗證(合併前一定做,強度跟風險相稱)
@@ -24,6 +26,12 @@ ARGUMENTS 含 `[專案: X]` 或 `--project-path X` → PROJECT_PATH = X,下方
24
26
  - subagent 回報「未驗證」的項目,由你補驗或明講仍未驗證。
25
27
  - 合併:`git merge` 後跑相關測試(或 `node .specialist/Ansuz/tools/verify-and-land.js`),用這個專案自己的測試指令。
26
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 會自動列出並讀最新那份。
34
+
27
35
  ## 回報與提問
28
36
  - 一律用用戶的語言(中文訊息 → 中文回覆,含提問選項)。
29
37
  - 需要用戶決定時用 `AskUserQuestion`(2-4 個選項、建議的放第一個),不要在文字裡列選項等回覆;有證據能自己判斷的事不問。
@@ -38,5 +46,7 @@ Kenaz 的工具、hook、安裝、peer、派工擋住或拖慢工作時,**繞
38
46
  ## 參考產品(REA)與學習教訓
39
47
  - banner `[rea]` 行:用戶提到參考產品時,有 REA 就提議分析,沒有就提示一次 `npx rea-agents setup`(範圍見 RULES.md 的 REA)。
40
48
  - 用戶糾正/重複指示、推翻決定、或驗證抓到失敗時,自動寫教訓並在回報點名一行:`node .specialist/tools/lessons.js add --scope user|project --type pref|fact|pitfall|check --text "..."`(規則見 RULES.md 的 Lessons)。
49
+ - banner 出現 `[lessons] N to graduate`:跑 `kenaz learn review`,提議把排第一的教訓改成代碼(測試、hook 檢查或專案 CLAUDE.md/AGENTS.md 規則,這是首選結果);糾正若與既有教訓相符屬於重複(recurrence),不是新教訓(`add` 會自動累加)。
50
+ - banner 出現 `[learn] N history items to review`:跑 `kenaz learn review`,每個歷史項目(返工、executor 失敗、blocked 卡、handoff 風險行)有現成的 `kenaz learn add ... --source harvest:<id>`,值得記的補上 why 後執行,其餘忽略;指標建議(各 rigor 一次過率、返工最多的檔案)只建議給用戶,不自行套用。詳見 RULES.md。
41
51
 
42
52
  需要更多規則時(授權自主、派工前、合併前、參考產品、寫教訓)讀 `.specialist/Ansuz/RULES.md`。
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+ // TASK_2426: correction detector fires on corrections only, is rate limited per session,
3
+ // and honours the mode gate. Temp project dirs only.
4
+ const test = require('node:test');
5
+ const assert = require('node:assert');
6
+ const fs = require('fs');
7
+ const os = require('os');
8
+ const path = require('path');
9
+ const { spawnSync } = require('child_process');
10
+ const { isCorrection } = require('../correction-detect');
11
+
12
+ const HOOK = path.join(__dirname, '..', 'correction-detect.js');
13
+
14
+ function tmpProject(mode) {
15
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kenaz-corr-'));
16
+ fs.mkdirSync(path.join(dir, '.kenaz'), { recursive: true });
17
+ fs.writeFileSync(path.join(dir, '.kenaz', 'project.yaml'), 'name: t\n');
18
+ if (mode) fs.writeFileSync(path.join(dir, '.kenaz', 'kenaz.yaml'), 'mode: ' + mode + '\n');
19
+ return dir;
20
+ }
21
+
22
+ function run(dir, prompt, session, extraEnv) {
23
+ const home = path.join(dir, 'home'); // keep the real ~/.kenaz/kenaz.yaml out of the test
24
+ const r = spawnSync(process.execPath, [HOOK], {
25
+ input: JSON.stringify({ session_id: session || 's1', prompt }),
26
+ encoding: 'utf8',
27
+ env: { ...process.env, CLAUDE_PROJECT_DIR: dir, USERPROFILE: home, HOME: home, KENAZ_MODE: '', ...extraEnv },
28
+ });
29
+ assert.strictEqual(r.status, 0);
30
+ return r.stdout;
31
+ }
32
+
33
+ test('corrections fire (Chinese and English)', () => {
34
+ for (const p of ['我們原本有bifrost', '不是這樣', '錯了,應該是用 lessons.js', '為什麼又壞了', '你還是沒修好',
35
+ 'no, use the other one', "that's wrong", 'we already have a helper for this', 'I told you to use temp dirs', 'still not working']) {
36
+ assert.ok(isCorrection(p), p);
37
+ }
38
+ });
39
+
40
+ test('neutral requests, slash commands and long pasted output do not fire', () => {
41
+ for (const p of ['發版', '然後確認一下還有沒有report', '做 kenaz launch --provider deepseek', 'please add a test',
42
+ '/review 不對', 'x'.repeat(1600) + ' 不是這樣', '<system-reminder>不對</system-reminder>']) {
43
+ assert.ok(!isCorrection(p), p);
44
+ }
45
+ });
46
+
47
+ test('emits one line with the right command per layout', () => {
48
+ const dir = tmpProject();
49
+ const out = run(dir, '不是這樣');
50
+ assert.strictEqual(out.trim().split('\n').length, 1);
51
+ assert.match(out, /kenaz learn add/);
52
+ const core = tmpProject();
53
+ fs.mkdirSync(path.join(core, '.specialist', 'tools'), { recursive: true });
54
+ fs.writeFileSync(path.join(core, '.specialist', 'tools', 'lessons.js'), '');
55
+ assert.match(run(core, '不是這樣'), /node \.specialist\/tools\/lessons\.js add/);
56
+ });
57
+
58
+ test('second correction in the same session within 10 minutes is silent; other session fires', () => {
59
+ const dir = tmpProject();
60
+ assert.ok(run(dir, '不是這樣', 'a'));
61
+ assert.strictEqual(run(dir, '錯了', 'a'), '');
62
+ assert.ok(run(dir, '錯了', 'b'));
63
+ });
64
+
65
+ test('mode off and assist are silent; neutral prompt is silent', () => {
66
+ assert.strictEqual(run(tmpProject('off'), '不是這樣'), '');
67
+ assert.strictEqual(run(tmpProject('assist'), '不是這樣'), '');
68
+ assert.strictEqual(run(tmpProject(), '不是這樣', 's', { KENAZ_MODE: 'off' }), '');
69
+ assert.strictEqual(run(tmpProject(), '發版'), '');
70
+ });
71
+
72
+ test('runner default list includes the hook', () => {
73
+ assert.ok(require('../_run-user-prompt').DEFAULT_HOOKS.includes('correction-detect.js'));
74
+ });
@@ -37,6 +37,8 @@ const DEFAULT_HOOKS = [
37
37
  // TASK_2218: ansuz-session-pin.js is called from turn-context.js (every install layout runs that hook).
38
38
  'turn-context.js',
39
39
  'task-bridge.js',
40
+ // TASK_2426: nudges Ansuz to record a lesson when the user corrects it.
41
+ 'correction-detect.js',
40
42
  ];
41
43
 
42
44
  const HARD_CAP_MS = 10000;
@@ -69,11 +69,11 @@ test('runner replays stdin, traps exits, concatenates stdout, exits 0', () => {
69
69
  assert.ok(elapsed < 5000, 'runner drained promptly, took ' + elapsed + ' ms');
70
70
  });
71
71
 
72
- test('default list is the three UserPromptSubmit hooks in settings order', () => {
72
+ test('default list is the UserPromptSubmit hooks in settings order', () => {
73
73
  // TASK_2218: the pin writer is invoked from turn-context.js, not listed here.
74
74
  const { DEFAULT_HOOKS } = require(RUNNER);
75
75
  assert.deepStrictEqual(DEFAULT_HOOKS, [
76
- 'daemon-outbox-hook.js', 'turn-context.js', 'task-bridge.js',
76
+ 'daemon-outbox-hook.js', 'turn-context.js', 'task-bridge.js', 'correction-detect.js',
77
77
  ]);
78
78
  for (const f of DEFAULT_HOOKS) assert.ok(fs.existsSync(path.join(__dirname, f)), f + ' exists');
79
79
  });
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * correction-detect.js — UserPromptSubmit (TASK_2426)
4
+ * RULES.md says Ansuz records a lesson when the user corrects it; in practice it never did.
5
+ * A deterministic pattern match on the user's prompt turns a correction into a one-line
6
+ * nudge. Silent on any error, mode off/assist, slash commands, pasted output, and when the
7
+ * session was already nudged in the last 10 minutes.
8
+ */
9
+ 'use strict';
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const os = require('os');
13
+
14
+ const MAX_PROMPT_CHARS = 1500;
15
+ const RATE_LIMIT_MS = 10 * 60 * 1000;
16
+
17
+ // Exported, case-insensitive. Kept narrow on purpose (eval target: <= 10% of prompts).
18
+ const CORRECTION_PATTERNS = [
19
+ /不是這樣|不是這個|不是這麼|不是這種|^\s*不是[,,。 ]|不對|不正確|錯了|搞錯|弄錯|理解錯/,
20
+ /我們原本有|我們本來有|原本就有|之前說過|我說過|跟你說過|不是說過|早就說過/,
21
+ /還是沒|還是不(行|對|能)|仍然沒|又(壞|錯|失敗|出錯|沒|忘|漏|搞)|為什麼又|怎麼又|為何又/,
22
+ /(?<!要)不要再|別再|應該是[^嗎吧呢??]{1,40}(才對|而不是)|應該要.{1,30}而不是/,
23
+ /^\s*no[,.!]\s|\bthat'?s (wrong|not (right|correct|what))|\bthat is (wrong|not (right|correct|what))/i,
24
+ /\bwe already (have|had|did)\b|\bi (already )?(told|said)\b|\bi just (told|said)\b|\bdon'?t do that again\b/i,
25
+ /\bwhy (did|do|are) you .{1,40}\bagain\b|\byou (did|made|broke|forgot).{1,40}\bagain\b|\bstill (not|broken|fails?|failing|wrong|doesn'?t)\b/i,
26
+ /\bnot what i (asked|meant|wanted)\b|\b(use|do) .{1,40} instead of\b|\bshould (be|have been) .{1,40}\bnot\b/i,
27
+ ];
28
+
29
+ function isCorrection(prompt) {
30
+ if (typeof prompt !== 'string') return false;
31
+ const p = prompt.trim();
32
+ if (!p || p.length > MAX_PROMPT_CHARS) return false;
33
+ if (p.startsWith('/')) return false;
34
+ if (/^\s*<(system-reminder|command-name|local-command|task-notification)|^\[(Turn Context|[A-Z_]+_CONTEXT)|^## (Role|Formation)\b/.test(p)) return false;
35
+ return CORRECTION_PATTERNS.some((re) => re.test(p));
36
+ }
37
+
38
+ function readYamlMode(file) {
39
+ try {
40
+ const m = fs.readFileSync(file, 'utf8').match(/^\s*mode:\s*['"]?(\w+)['"]?\s*(#.*)?$/m);
41
+ return m ? m[1].toLowerCase() : null;
42
+ } catch (_) { return null; }
43
+ }
44
+
45
+ // Same precedence as plugins/kenaz/hooks/mode.js (not importable: the plugin is not in every layout).
46
+ function resolveMode(dir) {
47
+ const ok = (m) => (m === 'off' || m === 'assist' || m === 'full' ? m : null);
48
+ return ok((process.env.KENAZ_MODE || '').toLowerCase())
49
+ || ok(readYamlMode(path.join(dir, '.kenaz', 'kenaz.yaml')))
50
+ || ok(readYamlMode(path.join(process.env.USERPROFILE || os.homedir(), '.kenaz', 'kenaz.yaml')))
51
+ || 'full';
52
+ }
53
+
54
+ function nudgeLine(dir) {
55
+ const core = fs.existsSync(path.join(dir, '.specialist', 'tools', 'lessons.js'));
56
+ // Same argument form both CLIs accept (--scope is required; project = this repo's pitfalls/checks, user = preferences).
57
+ const args = ' add --scope project|user --type check|fact|pref --text "<one-line lesson>" --source correction-nudge';
58
+ const cmd = (core ? 'node .specialist/tools/lessons.js' : 'kenaz learn') + args;
59
+ return '[NUDGE_CONTEXT: Background system signal, not user input.] This prompt looks like a correction: if it reveals a reusable lesson, record it with `' + cmd + '` (a repeat counts as a recurrence automatically); if it is a one-off, ignore this.';
60
+ }
61
+
62
+ /** True (and records the time) when this session has not been nudged within the window. */
63
+ function takeNudgeSlot(dir, sessionId, now) {
64
+ const file = path.join(dir, '.kenaz', 'specialist', 'correction-nudge.json');
65
+ let state = {};
66
+ try { state = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch (_) { /* first run */ }
67
+ const key = String(sessionId || 'unknown');
68
+ if (now - (Number(state[key]) || 0) < RATE_LIMIT_MS) return false;
69
+ state[key] = now;
70
+ for (const k of Object.keys(state)) if (now - Number(state[k]) > 24 * 3600 * 1000) delete state[k];
71
+ fs.mkdirSync(path.dirname(file), { recursive: true });
72
+ fs.writeFileSync(file, JSON.stringify(state));
73
+ return true;
74
+ }
75
+
76
+ /** One line per nudge for `kenaz stats` (conversion rate). Time and session only, never the prompt text. */
77
+ function logNudge(dir, sessionId, now) {
78
+ const file = path.join(dir, '.kenaz', 'specialist', 'correction-nudges.jsonl');
79
+ fs.mkdirSync(path.dirname(file), { recursive: true });
80
+ fs.appendFileSync(file, JSON.stringify({ ts: new Date(now).toISOString(), session_id: sessionId || null }) + '\n');
81
+ }
82
+
83
+ function main() {
84
+ try {
85
+ const raw = fs.readFileSync(0, 'utf8').trim();
86
+ const payload = raw ? JSON.parse(raw) : {};
87
+ if (!isCorrection(payload.prompt)) return;
88
+ const dir = require('./_project-guard').findProjectRoot(process.env.CLAUDE_PROJECT_DIR || process.cwd());
89
+ if (!dir || resolveMode(dir) !== 'full') return;
90
+ const now = Date.now();
91
+ if (!takeNudgeSlot(dir, payload.session_id, now)) return;
92
+ try { logNudge(dir, payload.session_id, now); } catch (_) { /* the nudge matters more than its log */ }
93
+ process.stdout.write(nudgeLine(dir) + '\n');
94
+ } catch (_) { /* silent */ }
95
+ }
96
+
97
+ module.exports = { CORRECTION_PATTERNS, isCorrection, resolveMode, nudgeLine, takeNudgeSlot, main };
98
+
99
+ if (require.main === module || require.main === undefined) main();
@@ -9,11 +9,10 @@ const { spawnSync } = require('child_process');
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
11
  const os = require('os');
12
- const cp = require('child_process');
13
12
 
14
13
  const TASKAPI = path.join(__dirname, 'task-api.js');
15
14
  const TASKJSON = path.join(__dirname, 'task-api-json.js');
16
- const { TaskAPI, toYAML } = require('./task-api.js');
15
+ const { toYAML } = require('./task-api.js');
17
16
 
18
17
  const SB = fs.mkdtempSync(path.join(os.tmpdir(), 't2288_'));
19
18
  const tasksDir = path.join(SB, '.kenaz', 'tasks');
@@ -37,20 +36,6 @@ const field = (id, k) => (findCard(id).text.match(new RegExp('^' + k + ':\\s*(.*
37
36
  const mkActive = (id) => fs.writeFileSync(path.join(tasksDir, 'active', id + '_t.yaml'),
38
37
  toYAML({ id, title: 't2288 ' + id, priority: 'P2', status: 'in_progress' }));
39
38
 
40
- // --- spawn counting seam (same as TASK_2264) ---
41
- let phases = [];
42
- const realSpawn = cp.spawn;
43
- cp.spawn = function (cmd, argv, opts) {
44
- const script = Array.isArray(argv) ? argv.find(a => typeof a === 'string' && a.includes('const CFG')) : null;
45
- if (script) {
46
- const m = script.match(/"phase":"(before|after)"/);
47
- phases.push(m ? m[1] : '?');
48
- return { unref() {}, on() {} };
49
- }
50
- return realSpawn.apply(this, arguments);
51
- };
52
- const afters = () => phases.filter(p => p === 'after').length;
53
-
54
39
  (async () => {
55
40
  try {
56
41
  // ---- (1) actual-hours validation (CLI, subprocess so spawn patch is irrelevant) ----
@@ -90,30 +75,10 @@ const afters = () => phases.filter(p => p === 'after').length;
90
75
  check('Z3 task-api-json emits estimated_hours 0 as 0', p2 && p2.estimated_hours === 0, JSON.stringify(p2 && p2.estimated_hours) + j2.stderr);
91
76
  check('Z4 task-api-json emits actual_hours 0 as 0', p2 && p2.actual_hours === 0, JSON.stringify(p2 && p2.actual_hours));
92
77
  }
93
-
94
- // ---- (3) single 'after' capture ----
95
- const api = new TaskAPI({ tasksDir, projectPath: SB });
96
- for (const envName of ['FORMATION_HEADLESS', 'FORMATION_SCHEDULER']) {
97
- const id = envName === 'FORMATION_HEADLESS' ? 'TASK_9111' : 'TASK_9112';
98
- mkActive(id); phases = [];
99
- process.env[envName] = '1';
100
- try { await api.completeTask(id, {}); } finally { delete process.env[envName]; }
101
- check(`R1 ${envName}: caller's post-complete owns 'after'; moveTask schedules none`, afters() === 0, JSON.stringify(phases));
102
- }
103
- mkActive('TASK_9113'); phases = [];
104
- process.env.FORMATION_HEADLESS = '1';
105
- try { await api.completeTask('TASK_9113', { skipPostComplete: true }); } finally { delete process.env.FORMATION_HEADLESS; }
106
- check('R2 control: headless + skipPostComplete (nobody else measures) keeps exactly 1 capture', afters() === 1, JSON.stringify(phases));
107
- mkActive('TASK_9114'); phases = [];
108
- await api.moveTask('TASK_9114', 'done');
109
- check('R3 control: bare move done captures exactly once', afters() === 1, JSON.stringify(phases));
110
- mkActive('TASK_9115'); phases = [];
111
- await api.completeTask('TASK_9115', { dryRun: true, skipGit: true, skipArchive: true });
112
- check('R4 control: in-process complete (TASK_2264) still schedules none', afters() === 0, JSON.stringify(phases));
78
+ // (3) the single 'after' memory-health capture checks were removed with the capture itself (TASK_2411)
113
79
  } catch (e) {
114
80
  check('no exception', false, e && e.stack);
115
81
  } finally {
116
- cp.spawn = realSpawn;
117
82
  try { fs.rmSync(SB, { recursive: true, force: true }); } catch { /* best effort */ }
118
83
  }
119
84
  console.log(`\n${pass} passed, ${fail} failed`);
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * TASK_2421 -- shared-room card replicas, unknown-id lookup, import, alias,
4
+ * local_fallback warning. Uses a local stub relay (never the real relay) and
5
+ * temp project dirs. YGGDRASIL_PUBLISHABLE_KEY is set so ~/.kenaz is never read.
6
+ */
7
+ const assert = require('assert');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const http = require('http');
12
+ const { execFile } = require('child_process');
13
+ const { TaskAPI } = require('./task-api.js');
14
+
15
+ process.env.YGGDRASIL_PUBLISHABLE_KEY = 'test-key';
16
+ const CLI = path.join(__dirname, 'task-api.js');
17
+
18
+ function startRelay({ failAllocate = false } = {}) {
19
+ const state = { rows: new Map(), nextId: 9000, posts: [], patches: [], authSeen: [] };
20
+ const server = http.createServer((req, res) => {
21
+ let body = '';
22
+ req.on('data', (c) => { body += c; });
23
+ req.on('end', () => {
24
+ state.authSeen.push(req.headers.authorization);
25
+ const url = new URL(req.url, 'http://x');
26
+ const json = (code, obj) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
27
+ if (req.method === 'POST' && url.pathname === '/api/tasks/allocate-ids') {
28
+ if (failAllocate) return json(500, { error: 'down' });
29
+ const start = state.nextId; state.nextId += 20;
30
+ return json(200, { start, end: start + 19 });
31
+ }
32
+ if (req.method === 'POST' && url.pathname === '/api/tasks') {
33
+ const b = JSON.parse(body); state.posts.push(b);
34
+ state.rows.set(b.task_id, { ...b, claimed_by: null, assigned_to: b.assigned_to || null });
35
+ return json(200, state.rows.get(b.task_id));
36
+ }
37
+ if (req.method === 'PATCH' && url.pathname.startsWith('/api/tasks/')) {
38
+ const id = decodeURIComponent(url.pathname.split('/').pop());
39
+ const b = JSON.parse(body); state.patches.push({ id, ...b });
40
+ const row = state.rows.get(id);
41
+ if (!row) return json(409, { error: 'conflict' });
42
+ for (const k of ['status', 'title', 'description', 'priority', 'full_yaml']) if (b[k] != null) row[k] = b[k];
43
+ return json(200, row);
44
+ }
45
+ if (req.method === 'GET' && url.pathname === '/api/tasks') {
46
+ const wanted = (url.searchParams.get('status') || 'pool').split(',');
47
+ return json(200, [...state.rows.values()].filter(r => wanted.includes(r.status)));
48
+ }
49
+ json(404, {});
50
+ });
51
+ });
52
+ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => {
53
+ resolve({ state, server, url: `ws://127.0.0.1:${server.address().port}` });
54
+ }));
55
+ }
56
+
57
+ function mkProject(yamlText) {
58
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'task2421-'));
59
+ fs.mkdirSync(path.join(root, '.kenaz', 'tasks'), { recursive: true });
60
+ if (yamlText) fs.writeFileSync(path.join(root, '.kenaz', 'yggdrasil.yaml'), yamlText);
61
+ return { root, api: new TaskAPI({ projectPath: root, tasksDir: path.join(root, '.kenaz', 'tasks') }) };
62
+ }
63
+
64
+ const cfg = (url, extra = '') => `enabled: true\nrelay_url: "${url}"\nyggdrasil_project: room\nnode_name: node-A\n${extra}`;
65
+ const card = (extra = {}) => ({ title: 'Replica me', description: 'desc', priority: 'P1', requirements: ['r1'], acceptance_criteria: ['a1'], force: true, ...extra });
66
+
67
+ function cli(root, args) {
68
+ return new Promise((resolve) => {
69
+ execFile(process.execPath, [CLI, '--project-path', root, ...args], { env: process.env, timeout: 30000 },
70
+ (err, stdout, stderr) => resolve({ code: err ? (err.code || 1) : 0, stdout, stderr }));
71
+ });
72
+ }
73
+
74
+ const tests = [];
75
+ const test = (n, f) => tests.push([n, f]);
76
+
77
+ test('shared room: create publishes a non-pool read-only replica', async () => {
78
+ const relay = await startRelay();
79
+ const { api } = mkProject(cfg(relay.url));
80
+ const t = await api.createTask(card({ alias: '791-17' }));
81
+ assert.strictEqual(relay.state.posts.length, 1);
82
+ const p = relay.state.posts[0];
83
+ assert.strictEqual(p.task_id, t.id);
84
+ assert.strictEqual(p.status, 'assigned', 'must not be claimable pool work');
85
+ assert.ok(!p.assigned_to, 'no assigned_to so creator inbox (pool_pull) stays clean');
86
+ assert.strictEqual(p.created_by, 'node-A');
87
+ assert.ok(/replica: true/.test(p.full_yaml) && /791-17/.test(p.full_yaml) && /origin_node: node-A/.test(p.full_yaml));
88
+ assert.ok(relay.state.authSeen.every(a => a === 'Bearer test-key'));
89
+ assert.strictEqual(t.replica_published, true);
90
+ relay.server.close();
91
+ });
92
+
93
+ test('opted out (share_cards: false) -> no replica; ids still from relay', async () => {
94
+ const relay = await startRelay();
95
+ const { api } = mkProject(cfg(relay.url, 'share_cards: false\n'));
96
+ const t = await api.createTask(card());
97
+ assert.strictEqual(relay.state.posts.length, 0);
98
+ assert.ok(/^TASK_9\d{3}$/.test(t.id));
99
+ relay.server.close();
100
+ });
101
+
102
+ test('not a shared room (no yggdrasil config) -> no relay traffic', async () => {
103
+ const relay = await startRelay();
104
+ const { api } = mkProject(null);
105
+ await api.createTask(card());
106
+ assert.strictEqual(relay.state.posts.length, 0);
107
+ relay.server.close();
108
+ });
109
+
110
+ test('relay down: local create still succeeds with one replica warning', async () => {
111
+ const relay = await startRelay();
112
+ const { api } = mkProject(cfg(relay.url));
113
+ const first = await api.createTask(card()); // gets a lease while relay is up
114
+ await new Promise(r => relay.server.close(r));
115
+ const warns = [];
116
+ const orig = console.warn; console.warn = (...a) => warns.push(a.join(' '));
117
+ let t;
118
+ try { t = await api.createTask(card({ title: 'Second' })); } finally { console.warn = orig; }
119
+ assert.ok(t && t.id && t.id !== first.id);
120
+ assert.ok(fs.existsSync(t._file_path), 'local card written');
121
+ assert.strictEqual(t.replica_published, false);
122
+ assert.strictEqual(warns.filter(w => /replica not published/.test(w)).length, 1);
123
+ });
124
+
125
+ test('local_fallback in a shared room warns loudly (stderr + create output)', async () => {
126
+ const { api, root } = mkProject(cfg('ws://127.0.0.1:1')); // unreachable relay, no lease
127
+ const r = await cli(root, ['create', '--title', 'Fallback', '--force']);
128
+ assert.strictEqual(r.code, 0, r.stderr);
129
+ assert.ok(/ID COLLISION RISK/.test(r.stderr), 'stderr warning');
130
+ assert.ok(/⚠️.*locally because the relay is unreachable/.test(r.stdout), 'create output warning');
131
+ assert.ok(api);
132
+ });
133
+
134
+ test('solo project local id allocation prints no collision warning', async () => {
135
+ const { root } = mkProject(null);
136
+ const r = await cli(root, ['create', '--title', 'Solo', '--force']);
137
+ assert.strictEqual(r.code, 0, r.stderr);
138
+ assert.ok(!/COLLISION/.test(r.stderr + r.stdout));
139
+ });
140
+
141
+ test('get unknown id shows replica with owner node; mutations explain, create no local file', async () => {
142
+ const relay = await startRelay();
143
+ const a = mkProject(cfg(relay.url));
144
+ const t = await a.api.createTask(card());
145
+ // node B: same room, empty local store
146
+ const b = mkProject(cfg(relay.url).replace('node-A', 'node-B'));
147
+ const g = await cli(b.root, ['get', t.id]);
148
+ assert.strictEqual(g.code, 0, g.stderr);
149
+ const out = JSON.parse(g.stdout);
150
+ assert.strictEqual(out.read_only_replica, true);
151
+ assert.strictEqual(out.title, 'Replica me');
152
+ assert.ok(/read-only replica; card lives on node node-A/.test(out.replica_notice));
153
+ assert.ok(/task-api\.js pull /.test(out.replica_notice));
154
+ for (const args of [['move', t.id, 'active'], ['update', t.id, '--title', 'x'], ['complete', t.id]]) {
155
+ const r = await cli(b.root, args);
156
+ assert.notStrictEqual(r.code, 0, args.join(' '));
157
+ assert.ok(/read-only replica; card lives on node node-A/.test(r.stderr + r.stdout), args.join(' ') + ': ' + r.stderr);
158
+ }
159
+ const files = fs.readdirSync(path.join(b.root, '.kenaz', 'tasks'), { recursive: true }).filter(f => /TASK_/.test(String(f)));
160
+ assert.strictEqual(files.length, 0, 'no broken local file: ' + files.join(','));
161
+ // truly unknown id still says not found
162
+ const miss = await cli(b.root, ['get', 'TASK_424242']);
163
+ assert.notStrictEqual(miss.code, 0);
164
+ assert.ok(/任務不存在/.test(miss.stderr));
165
+ relay.server.close();
166
+ });
167
+
168
+ test('status changes on the owner keep the replica current', async () => {
169
+ const relay = await startRelay();
170
+ const { api } = mkProject(cfg(relay.url));
171
+ const t = await api.createTask(card());
172
+ await api.moveTask(t.id, 'active');
173
+ assert.strictEqual(relay.state.rows.get(t.id).status, 'in_progress');
174
+ await api.updateTask(t.id, { title: 'Renamed' });
175
+ assert.strictEqual(relay.state.rows.get(t.id).title, 'Renamed');
176
+ relay.server.close();
177
+ });
178
+
179
+ test('pull imports a local copy marked with origin node', async () => {
180
+ const relay = await startRelay();
181
+ const a = mkProject(cfg(relay.url));
182
+ const t = await a.api.createTask(card({ alias: '791-18' }));
183
+ const b = mkProject(cfg(relay.url).replace('node-A', 'node-B'));
184
+ const r = await cli(b.root, ['pull', t.id]);
185
+ assert.strictEqual(r.code, 0, r.stderr);
186
+ const local = await b.api.getTask(t.id);
187
+ assert.ok(local, 'local copy exists');
188
+ assert.strictEqual(local.origin_node, 'node-A');
189
+ assert.strictEqual(local.title, 'Replica me');
190
+ assert.strictEqual(local.alias, '791-18');
191
+ const again = await cli(b.root, ['pull', t.id]);
192
+ assert.notStrictEqual(again.code, 0);
193
+ relay.server.close();
194
+ });
195
+
196
+ test('alias: create/update/get/search resolve to the TASK id', async () => {
197
+ const { api, root } = mkProject(null);
198
+ const t = await api.createTask(card({ alias: '791-17' }));
199
+ const g = await cli(root, ['get', '791-17']);
200
+ assert.strictEqual(g.code, 0, g.stderr);
201
+ assert.strictEqual(JSON.parse(g.stdout).id, t.id);
202
+ const s = await cli(root, ['search', '791-17']);
203
+ assert.ok(s.stdout.includes(t.id));
204
+ const u = await cli(root, ['update', t.id, '--alias', '791-99']);
205
+ assert.strictEqual(u.code, 0, u.stderr);
206
+ const g2 = await cli(root, ['get', '791-99']);
207
+ assert.strictEqual(JSON.parse(g2.stdout).id, t.id);
208
+ });
209
+
210
+ test('get by alias of a replica that exists only on the relay', async () => {
211
+ const relay = await startRelay();
212
+ const a = mkProject(cfg(relay.url));
213
+ const t = await a.api.createTask(card({ alias: '791-20' }));
214
+ const b = mkProject(cfg(relay.url).replace('node-A', 'node-B'));
215
+ const g = await cli(b.root, ['get', '791-20']);
216
+ assert.strictEqual(g.code, 0, g.stderr);
217
+ assert.strictEqual(JSON.parse(g.stdout).id, t.id);
218
+ relay.server.close();
219
+ });
220
+
221
+ (async () => {
222
+ let failed = 0;
223
+ for (const [name, fn] of tests) {
224
+ try { await fn(); console.log(` ok ${name}`); }
225
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e && e.stack || e}`); }
226
+ }
227
+ console.log(`\n${tests.length - failed}/${tests.length} passed`);
228
+ process.exit(failed ? 1 : 0);
229
+ })();