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
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
  /**
4
- * Dispatch-agent model for a project, without the Dashboard (`kenaz model`).
4
+ * Dispatch-agent model for a project, without the Dashboard (`kenaz subagent`).
5
5
  * Reads and writes <project>/.kenaz/dashboard-models.json (dispatchModel, dispatchModelByRigor),
6
6
  * the same file and resolution dispatch.js uses; the core's copy is only a read fallback.
7
7
  * The specialist (Ansuz) runs in the user's own Claude Code / Codex session: its model is that
@@ -15,6 +15,7 @@ const fs = require('fs');
15
15
  const path = require('path');
16
16
  const { ROOT, readDashboardModels } = require('./utils');
17
17
  const { resolveDispatchModelForRigor, translateModelForAgentTool } = require('./dispatch');
18
+ const { parseExecutorModel } = require('../bifrost/exec');
18
19
 
19
20
  const RIGORS = ['low', 'medium', 'high'];
20
21
  const fileOf = (dir) => path.join(dir, '.kenaz', 'dashboard-models.json');
@@ -32,8 +33,10 @@ function describe(projectPath) {
32
33
  const family = (models && models.dispatchFamily) || 'claude';
33
34
  const rows = RIGORS.map((rigor) => {
34
35
  const r = resolveDispatchModelForRigor({ rigor, models, cliDispatchModel: null });
35
- const agent = family === 'bifrost' ? null : translateModelForAgentTool(r.rawModel).model;
36
- return { rigor, model: r.rawModel, source: r.label, agentModel: agent };
36
+ let executor = null;
37
+ try { executor = parseExecutorModel(r.rawModel); } catch { /* shown as-is; dispatch.js refuses it */ }
38
+ const agent = family === 'bifrost' || executor || /^\w+:/.test(r.rawModel) ? null : translateModelForAgentTool(r.rawModel).model;
39
+ return { rigor, model: r.rawModel, source: r.label, agentModel: agent, executor: executor ? executor.adapter : null };
37
40
  });
38
41
  return { file, family, rows };
39
42
  }
@@ -45,9 +48,11 @@ function setModel(projectPath, modelId, rigor = null) {
45
48
  const id = modelId == null ? null : String(modelId).trim();
46
49
  if (id !== null) {
47
50
  if (!id) throw new Error('model id is empty');
51
+ // codex:<id> / deepseek:<model> run through .formation/bifrost/exec.js (throws, listing the valid ids, if invalid).
52
+ const executor = parseExecutorModel(id);
48
53
  // Same rule dispatch.js enforces at dispatch time: a Claude id must map onto the Agent tool enum.
49
- if ((cur.dispatchFamily || 'claude') !== 'bifrost' && !translateModelForAgentTool(id).model) {
50
- throw new Error(`'${id}' is not a Claude model id the Agent tool can run (expected claude-<opus|sonnet|haiku|fable>-..., or opus|sonnet|haiku|fable)`);
54
+ if (!executor && (cur.dispatchFamily || 'claude') !== 'bifrost' && !translateModelForAgentTool(id).model) {
55
+ throw new Error(`'${id}' is not a Claude model id the Agent tool can run (expected claude-<opus|sonnet|haiku|fable>-..., or opus|sonnet|haiku|fable; or codex:<id> / deepseek:<model>)`);
51
56
  }
52
57
  }
53
58
  const next = { ...cur };
@@ -66,25 +71,25 @@ function setModel(projectPath, modelId, rigor = null) {
66
71
 
67
72
  function render(d) {
68
73
  const lines = [`dispatch model (${d.file ? d.file : 'no dashboard-models.json; built-in default'})`];
69
- for (const r of d.rows) lines.push(` ${r.rigor.padEnd(6)} ${r.model}${r.agentModel ? ` -> Agent ${r.agentModel}` : ''} [${r.source}]`);
74
+ for (const r of d.rows) lines.push(` ${r.rigor.padEnd(6)} ${r.model}${r.agentModel ? ` -> Agent ${r.agentModel}` : ''}${r.executor ? ` -> executor ${r.executor} (bifrost/exec.js)` : ''} [${r.source}]`);
70
75
  lines.push('specialist (Ansuz): the model of your Claude Code / Codex session (/model)');
71
76
  return lines.join('\n');
72
77
  }
73
78
 
74
- /** CLI entry shared with `kenaz model`. Returns an exit code. */
79
+ /** CLI entry shared with `kenaz subagent`. Returns an exit code. */
75
80
  function main(argv, out = console.log, cwd = process.cwd()) {
76
81
  const val = (k) => { const i = argv.indexOf('--' + k); return i >= 0 ? argv[i + 1] : undefined; };
77
82
  const projectPath = path.resolve(val('project-path') || cwd);
78
83
  const pos = argv.filter((a, i) => !a.startsWith('--') && !['--rigor', '--project-path'].includes(argv[i - 1]));
79
84
  try {
80
85
  if (pos[0] === 'set') {
81
- if (!pos[1]) { out('usage: kenaz model set <model-id> [--rigor low|medium|high]'); return 2; }
86
+ if (!pos[1]) { out('usage: kenaz subagent set <model-id> [--rigor low|medium|high]'); return 2; }
82
87
  out(`wrote ${setModel(projectPath, pos[1], val('rigor') || null)}`);
83
88
  } else if (pos[0] === 'unset') out(`wrote ${setModel(projectPath, null, val('rigor') || null)}`);
84
- else if (pos[0]) { out('usage: kenaz model [set <model-id> | unset] [--rigor low|medium|high] [--project-path <dir>]'); return 2; }
89
+ else if (pos[0]) { out('usage: kenaz subagent [set <model-id> | unset] [--rigor low|medium|high] [--project-path <dir>]'); return 2; }
85
90
  out(render(describe(projectPath)));
86
91
  return 0;
87
- } catch (e) { out('kenaz model: ' + e.message); return 1; }
92
+ } catch (e) { out('kenaz subagent: ' + e.message); return 1; }
88
93
  }
89
94
 
90
95
  if (require.main === module) process.exit(main(process.argv.slice(2)));
@@ -39,7 +39,7 @@ const {
39
39
  // getSubagentDispatchParams during evaluation, so a later `const` would be in
40
40
  // its temporal dead zone at call time.)
41
41
  const AGENT_TOOL_MODEL_ENUM = ['sonnet', 'opus', 'haiku', 'fable'];
42
- // Used when no dashboard-models.json sets dispatchModel (set one with `kenaz model set <id>`).
42
+ // Used when no dashboard-models.json sets dispatchModel (set one with `kenaz subagent set <id>`).
43
43
  const DEFAULT_DISPATCH_MODEL = 'claude-sonnet-5-5';
44
44
 
45
45
  /**
@@ -1016,22 +1016,34 @@ async function getSubagentDispatchParams(options) {
1016
1016
  // 2b: a rejected dispatch must never acquire locks it won't use).
1017
1017
  const resolution = resolveDispatchModelForRigor({ rigor, models, cliDispatchModel: dispatchModelOverride });
1018
1018
  const rawModel = resolution.rawModel;
1019
+ // TASK_2422: codex:<id> / deepseek:<model> run via .formation/bifrost/exec.js, not the Agent tool.
1020
+ let exec = null;
1021
+ if (!isCodex) {
1022
+ try { exec = require('../bifrost/exec').parseExecutorModel(rawModel); } catch (e) {
1023
+ console.error(`❌ ${resolution.label} '${rawModel}': ${e.message}`);
1024
+ return null;
1025
+ }
1026
+ if (exec && exec.adapter === 'deepseek' && !dryRun && !require('../bifrost/exec').deepseekKey()) {
1027
+ console.error('❌ deepseek executor: no API key (set DEEPSEEK_API_KEY or deepseek_api_key in ~/.kenaz/credentials.yaml)');
1028
+ return null;
1029
+ }
1030
+ }
1019
1031
  // TASK_2315: Codex inherits the session model — nothing to translate or pass.
1020
- const translated = isCodex ? { model: null, lossless: true } : translateModelForAgentTool(rawModel);
1021
- if (!isCodex && !translated.model) {
1032
+ const translated = isCodex || exec ? { model: null, lossless: true } : translateModelForAgentTool(rawModel);
1033
+ if (!isCodex && !exec && !translated.model) {
1022
1034
  console.error(`❌ ${resolution.label} '${rawModel}' cannot be mapped to an Agent tool model family (${AGENT_TOOL_MODEL_ENUM.join('|')}) — refusing to silently guess a substitute model. Fix the model selection (Quick Actions → Default Models).`);
1023
1035
  return null;
1024
1036
  }
1025
1037
  const dispatchModel = translated.model;
1026
1038
  const modelTranslation = isCodex
1027
1039
  ? { requested: rawModel, inherited: true }
1028
- : { requested: rawModel, agentToolModel: dispatchModel, granularityLoss: !translated.lossless };
1040
+ : exec ? { requested: rawModel, executor: exec.adapter } : { requested: rawModel, agentToolModel: dispatchModel, granularityLoss: !translated.lossless };
1029
1041
  if (!translated.lossless) {
1030
1042
  process.stderr.write(`[dispatch] model '${rawModel}' → Agent tool family '${dispatchModel}' — the Agent tool only expresses family names (${AGENT_TOOL_MODEL_ENUM.join('|')}); version/speed selections (e.g. 5 vs 4.x, [1m]) are NOT expressible on the subagent path. If the exact version matters, choose a model whose family the Agent tool can express.\n`);
1031
1043
  }
1032
1044
  // TASK_1998 R3 (+ two-axis policy): the resolved choice, always printed
1033
1045
  // (unlike the granularity block above, which only fires on lossy translation).
1034
- process.stderr.write(`[dispatch] rigor=${resolution.rigorKey} complexity=${complexity} model=${rawModel} → ${isCodex ? 'inherited by Codex session' : "family '" + dispatchModel + "'"} (source: ${resolution.source})\n`);
1046
+ process.stderr.write(`[dispatch] rigor=${resolution.rigorKey} complexity=${complexity} model=${rawModel} → ${isCodex ? 'inherited by Codex session' : exec ? 'executor ' + exec.adapter + ' (bifrost/exec.js)' : "family '" + dispatchModel + "'"} (source: ${resolution.source})\n`);
1035
1047
 
1036
1048
  // TASK_2362: subagent is the only route. dispatchMode is a legacy config echo:
1037
1049
  // missing -> 'subagent'; 'subagent'|'auto' accepted; 'formation' (retired) or
@@ -1101,8 +1113,9 @@ async function getSubagentDispatchParams(options) {
1101
1113
  // TASK_2315: Codex has no harness-managed worktree isolation — always pre-create
1102
1114
  // one (same-project included) and hand its path to the agent as cwd.
1103
1115
  const wtRepo2315 = projectPath || ROOT;
1104
- if (isCodex && iso.mode === 'agent_tool_worktree') { iso.mode = 'target_repo_worktree'; iso.source = 'codex-harness'; }
1105
- const isCrossProject = iso.isCrossProject || (isCodex && iso.mode === 'target_repo_worktree');
1116
+ // TASK_2422: executors (codex:/deepseek:) have no harness worktree either.
1117
+ if ((isCodex || exec) && iso.mode === 'agent_tool_worktree') { iso.mode = 'target_repo_worktree'; iso.source = isCodex ? 'codex-harness' : 'executor'; }
1118
+ const isCrossProject = iso.isCrossProject || ((isCodex || exec) && iso.mode === 'target_repo_worktree');
1106
1119
  let crossWorktree = null; // { worktreePath, branchName } when mode=target_repo_worktree
1107
1120
  let serialLock = null; // { lockFile } when mode=serial_lock
1108
1121
  let worktreeFallbackReason = null; // TASK_2067: non-null only on a genuine target_repo_worktree creation failure that degraded to serial_lock -- rides in the isolation payload, not just stderr
@@ -1139,6 +1152,13 @@ async function getSubagentDispatchParams(options) {
1139
1152
  }
1140
1153
  }
1141
1154
 
1155
+ if (exec && iso.mode !== 'target_repo_worktree') {
1156
+ console.error(`❌ executor ${exec.adapter} needs a git worktree for the card (isolation mode '${iso.mode}'${worktreeFallbackReason ? ': ' + worktreeFallbackReason : ''}) — refusing to run a non-Claude agent in a shared tree`);
1157
+ releaseResourceLocks(_saAcquiredResources, _saRoot);
1158
+ releaseAdoptedClaimMarker(_saClaim);
1159
+ return null;
1160
+ }
1161
+
1142
1162
  if (iso.mode === 'serial_lock' && dryRun) {
1143
1163
  console.error(' [dry-run] shared-tree dispatch lock NOT acquired: ' + dispatchLockPath(projectPath));
1144
1164
  } else if (iso.mode === 'serial_lock') {
@@ -1254,7 +1274,7 @@ async function getSubagentDispatchParams(options) {
1254
1274
  // Still true: the description field does not affect the worktree name, and per-TASK
1255
1275
  // naming (task_<taskid>) requires the formation path via
1256
1276
  // formations.setupUnitWorktree({ taskId }).
1257
- const agentParams = isCodex ? undefined : {
1277
+ const agentParams = isCodex || exec ? undefined : {
1258
1278
  // Include taskInput so the Agent tool description is unique per task (helps tracing)
1259
1279
  description: `[${taskInput}] Kenaz subagent (${skill})`,
1260
1280
  model: dispatchModel,
@@ -1315,7 +1335,7 @@ async function getSubagentDispatchParams(options) {
1315
1335
  // by dashboard vs dashboard-by-rigor vs cli without re-deriving it.
1316
1336
  // Two-axis policy: complexity rides along too, so dispatch-stats.js can
1317
1337
  // group by (rigor, complexity) instead of rigor alone.
1318
- if (!dryRun) appendDispatchPromptStats({ taskId: taskInput, rigor: resolution.rigorKey, complexity, formation, promptText, model: dispatchModel, modelSource: resolution.source, projectPath });
1338
+ if (!dryRun) appendDispatchPromptStats({ taskId: taskInput, rigor: resolution.rigorKey, complexity, formation, promptText, model: dispatchModel || rawModel, modelSource: resolution.source, projectPath });
1319
1339
 
1320
1340
  // TASK_2032: mirror headless.js's formation-path auto-activation (~L272-290)
1321
1341
  // on the subagent path — every gate/guard above has passed and this function
@@ -1355,10 +1375,26 @@ async function getSubagentDispatchParams(options) {
1355
1375
  // on agentParams itself). rigor is the NORMALIZED key (null/unrecognized →
1356
1376
  // 'medium') that actually drove model resolution, not the raw card field.
1357
1377
  // complexity is likewise the normalized CONTEXT-volume key.
1378
+ // TASK_2422: executor block = the card's worktree + one command for Ansuz to run with Bash run_in_background.
1379
+ // The prompt goes to exec.js as a file (never argv); no secret is ever part of this payload.
1380
+ let executor;
1381
+ if (exec) {
1382
+ const dir = path.join(require('os').tmpdir(), 'kenaz-exec');
1383
+ const stem = path.join(dir, `${(taskInput || 'adhoc').replace(/[^\w.-]/g, '_')}-${Date.now().toString(36)}`);
1384
+ const promptFile = stem + '.prompt.md'; const resultFile = stem + '.result.json';
1385
+ if (!dryRun) { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(promptFile, promptText); }
1386
+ const q = (s) => '"' + String(s).replace(/"/g, '\\"') + '"';
1387
+ executor = {
1388
+ adapter: exec.adapter, model: exec.id, worktreePath: crossWorktree.worktreePath, branchName: crossWorktree.branchName,
1389
+ promptFile, resultFile,
1390
+ command: `node ${q(path.join(__dirname, '..', 'bifrost', 'exec.js'))} --adapter=${exec.adapter} --model=${q(exec.id)} --worktree ${q(crossWorktree.worktreePath)} --prompt-file ${q(promptFile)} --result-file ${q(resultFile)}`,
1391
+ note: 'Run command with Bash run_in_background, wait for the completion notice, read resultFile (ok, exitCode, lastMessage, commits), then verify and merge the branch as for an Agent subagent.',
1392
+ };
1393
+ }
1358
1394
  return {
1359
1395
  promptText, taskId: taskInput, formation, skill, dispatchMode, effectiveMode: 'subagent',
1360
1396
  ...(isCodex ? { harness: 'codex', cwd: crossWorktree ? crossWorktree.worktreePath : null, worktreePath: crossWorktree ? crossWorktree.worktreePath : null } : {}),
1361
- modelTranslation, isolation, ...(agentParams ? { agentParams } : {}), rigor: resolution.rigorKey, complexity, modelSource: resolution.source,
1397
+ modelTranslation, isolation, ...(agentParams ? { agentParams } : {}), ...(executor ? { executor } : {}), rigor: resolution.rigorKey, complexity, modelSource: resolution.source,
1362
1398
  ...(dryRun ? { dryRun: true } : {}),
1363
1399
  };
1364
1400
  }
@@ -7,6 +7,7 @@
7
7
  * 2. dispatch git history of the project: `Merge TASK_n` commits vs `[TASK_n]` commits (first-pass = one merge, no later fix-up on the main line)
8
8
  * 3. field reports ~/.kenaz/cortex/field_reports.db (card_id) -> the card's merge commit -> first release tag containing it
9
9
  * 4. alaya ~/.kenaz/cortex/global_memory.db (alaya_bijas, alaya_exported, seed_imports)
10
+ * 5. lessons <project>/.kenaz/knowledge and ~/.kenaz/knowledge (active, fires, recurrences, graduated / merged / archived)
10
11
  */
11
12
  const fs = require('fs');
12
13
  const os = require('os');
@@ -76,36 +77,36 @@ function git(cwd, ...args) {
76
77
  return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 << 20 });
77
78
  }
78
79
  /** Commits of the repo (all refs): [{sha, parents, t, subject, firstParent, onHead}] newest first; null when not a git repo. */
79
- function readCommits(repo, sinceMs) {
80
+ function readCommits(repo, sinceMs, untilMs) {
80
81
  try {
81
82
  const fmt = '%H%x09%P%x09%ct%x09%s';
82
83
  const head = new Set(git(repo, 'rev-list', 'HEAD').split('\n').filter(Boolean));
83
84
  const first = new Set(git(repo, 'log', '--first-parent', '--format=%H').split('\n').filter(Boolean));
84
- return git(repo, 'log', '--all', `--since=${new Date(sinceMs).toISOString()}`, `--format=${fmt}`).split('\n').filter(Boolean).map((l) => {
85
+ return git(repo, 'log', '--all', `--since=${new Date(sinceMs).toISOString()}`, ...(untilMs ? [`--until=${new Date(untilMs).toISOString()}`] : []), `--format=${fmt}`).split('\n').filter(Boolean).map((l) => {
85
86
  const [sha, parents, ct, ...s] = l.split('\t');
86
87
  return { sha, parents: parents.split(' ').filter(Boolean), t: Number(ct) * 1000, subject: s.join('\t'), firstParent: first.has(sha), onHead: head.has(sha) };
87
88
  });
88
89
  } catch (_) { return null; }
89
90
  }
90
91
 
91
- /** Commits the merged branch gained after the agent's first `task completed` commit (fixes before the merge). */
92
+ /** Shas of commits the merged branch gained after the agent's first `task completed` commit (fixes before the merge). */
92
93
  function branchFixups(repo, merges) {
93
- let n = 0;
94
+ const shas = [];
94
95
  for (const m of merges) {
95
96
  if (m.parents.length < 2) continue;
96
97
  try {
97
- const subj = git(repo, 'log', '--reverse', '--format=%s', `${m.parents[0]}..${m.parents[1]}`).split('\n').filter(Boolean);
98
- const first = subj.findIndex((s) => /task completed\s*$/i.test(s));
98
+ const rows = git(repo, 'log', '--reverse', '--format=%H\t%s', `${m.parents[0]}..${m.parents[1]}`).split('\n').filter(Boolean).map((l) => l.split('\t'));
99
+ const first = rows.findIndex(([, s]) => /task completed\s*$/i.test(s));
99
100
  // the agent's own follow-ups reuse the `task completed` subject; anything else was added after delivery
100
- if (first >= 0) n += subj.slice(first + 1).filter((s) => !/task completed\s*$/i.test(s)).length;
101
+ if (first >= 0) shas.push(...rows.slice(first + 1).filter(([, s]) => !/task completed\s*$/i.test(s)).map(([h]) => h));
101
102
  } catch (_) { /* unreadable merge: not judged */ }
102
103
  }
103
- return n;
104
+ return shas;
104
105
  }
105
106
 
106
- function dispatchStats({ repo, now, days }) {
107
+ function dispatchStats({ repo, now, days, until }) {
107
108
  const src = 'git log of ' + repo + ' (`Merge TASK_n` commits, `[TASK_n]` commits)';
108
- const commits = readCommits(repo, now - days * DAY);
109
+ const commits = readCommits(repo, now - days * DAY, until);
109
110
  if (!commits) return { source: src, note: `${NO_DATA} (not a git repository)` };
110
111
  const cards = new Map(); // n -> {merges:[commit], work:number, fixups:[]}
111
112
  const card = (n) => { if (!cards.has(n)) cards.set(n, { merges: [], work: [], fixups: [] }); return cards.get(n); };
@@ -126,14 +127,15 @@ function dispatchStats({ repo, now, days }) {
126
127
  const after = c.fixups.filter((x) => x.t >= firstMerge);
127
128
  const abandoned = c.work.filter((x) => /task completed\s*$/i.test(x.subject) && !x.onHead).length;
128
129
  const fixedOnBranch = branchFixups(repo, c.merges);
129
- if (c.merges.length > 1) rework.push({ card: `TASK_${n}`, why: `re-dispatched/merged ${c.merges.length} times` });
130
- else if (abandoned) rework.push({ card: `TASK_${n}`, why: `${abandoned} agent delivery commit(s) never merged (re-dispatch or a losing A/B arm)` });
131
- else if (fixedOnBranch) rework.push({ card: `TASK_${n}`, why: `${fixedOnBranch} commit(s) on the branch after the agent's delivery, before the merge` });
132
- else if (after.length) rework.push({ card: `TASK_${n}`, why: `${after.length} fix-up commit(s) on the main line after the merge` });
130
+ // `shas`: the extra commits made after delivery (TASK_2427 harvest reads the files they touched)
131
+ if (c.merges.length > 1) rework.push({ card: `TASK_${n}`, why: `re-dispatched/merged ${c.merges.length} times`, shas: [] });
132
+ else if (abandoned) rework.push({ card: `TASK_${n}`, why: `${abandoned} agent delivery commit(s) never merged (re-dispatch or a losing A/B arm)`, shas: c.work.filter((x) => !x.onHead).map((x) => x.sha) });
133
+ else if (fixedOnBranch.length) rework.push({ card: `TASK_${n}`, why: `${fixedOnBranch.length} commit(s) on the branch after the agent's delivery, before the merge`, shas: fixedOnBranch });
134
+ else if (after.length) rework.push({ card: `TASK_${n}`, why: `${after.length} fix-up commit(s) on the main line after the merge`, shas: after.map((x) => x.sha) });
133
135
  }
134
136
  return {
135
137
  source: src, dispatched: cards.size, merged: merged.length, landedWithoutMergeCommit: noMergeCommit.length, notMerged: unmerged.length, firstPass: merged.length - rework.length,
136
- firstPassRate: merged.length ? (merged.length - rework.length) / merged.length : null, rework, notMergedCards: unmerged.slice(0, 20),
138
+ firstPassRate: merged.length ? (merged.length - rework.length) / merged.length : null, rework, mergedCards: merged, notMergedCards: unmerged.slice(0, 20),
137
139
  note: 'dispatched = cards with any [TASK_n] or Merge TASK_n commit in the window; first-pass rate = cards with a Merge TASK_n commit on HEAD, merged once, no main-line fix-up; cards landed without a merge commit (fast-forward) are not judged; not merged = commits not reachable from HEAD (first 20 listed)',
138
140
  };
139
141
  }
@@ -200,6 +202,24 @@ function alayaStats({ home, now, days }) {
200
202
  };
201
203
  }
202
204
 
205
+ function lessonStats({ home, cwd, now, days }) {
206
+ const src = '<project>/.kenaz/knowledge + ~/.kenaz/knowledge (+ archive/, .usage.jsonl, memory_usage.jsonl)';
207
+ try {
208
+ return { source: src, ...require('../../.specialist/tools/lessons.js').stats({ projectPath: cwd, home, now, days }) };
209
+ } catch (e) { return { source: src, note: `${NO_DATA} (${e.message})` }; }
210
+ }
211
+
212
+ function selfImprovementStats({ home, cwd, now, days }) {
213
+ const src = '<project>/.kenaz/specialist/correction-nudges.jsonl + lessons + .kenaz/knowledge/.harvested.json + git (this window vs the previous one)';
214
+ try {
215
+ const cur = dispatchStats({ repo: cwd, now, days });
216
+ const prev = dispatchStats({ repo: cwd, now: now - days * DAY, days, until: now - days * DAY });
217
+ const pick = (d) => (d.firstPassRate == null ? null : { rate: d.firstPassRate, merged: d.merged });
218
+ return { source: src, ...require('../../.specialist/tools/lessons-harvest.js').selfStats({ projectPath: cwd, home, now, days }),
219
+ firstPass: { window: pick(cur), previous: pick(prev) } };
220
+ } catch (e) { return { source: src, note: `${NO_DATA} (${e.message})` }; }
221
+ }
222
+
203
223
  function collect({ home = os.homedir(), cwd = process.cwd(), days = 14, now = Date.now() } = {}) {
204
224
  return {
205
225
  days,
@@ -207,6 +227,8 @@ function collect({ home = os.homedir(), cwd = process.cwd(), days = 14, now = Da
207
227
  dispatch: dispatchStats({ repo: cwd, now, days }),
208
228
  fieldReports: fieldStats({ home, repo: cwd, now, days }),
209
229
  alaya: alayaStats({ home, now, days }),
230
+ lessons: lessonStats({ home, cwd, now, days }),
231
+ selfImprovement: selfImprovementStats({ home, cwd, now, days }),
210
232
  };
211
233
  }
212
234
 
@@ -243,6 +265,25 @@ function render(s) {
243
265
  ` activated: ${a.bijasWithActivation || 0} bijas with >=1 activation, ${a.activationsTotal} activations all-time`,
244
266
  ` seeds exported: ${a.seedsExportedTotal} all-time, ${a.seedsExportedInWindow || 0} in window; seed imports in window ${a.seedImportsInWindow}`, ` note: ${a.note}`);
245
267
  }
268
+ const le = s.lessons || { source: '-', note: 'no data' };
269
+ L.push('', 'Lessons', ` source: ${le.source}`);
270
+ if (!le.project) L.push(` ${le.note}`);
271
+ else for (const k of ['project', 'user']) {
272
+ const x = le[k];
273
+ L.push(` ${k}: active ${x.active}, fires in window ${x.fires}, recurrences ${x.recurrences}; graduated ${x.graduated}, merged ${x.merged}, archived ${x.archived}`);
274
+ }
275
+ const si = s.selfImprovement;
276
+ if (si) {
277
+ L.push('', 'Self-improvement', ` source: ${si.source}`);
278
+ if (si.nudges == null) L.push(` ${si.note}`);
279
+ else {
280
+ const r = (x) => (x ? `${(x.rate * 100).toFixed(0)}% of ${x.merged}` : NO_DATA);
281
+ L.push(` correction nudges ${si.nudges}: lesson recorded within 15 min after ${si.converted}${si.nudges ? ` (${(100 * si.converted / si.nudges).toFixed(0)}%)` : ''}`,
282
+ ` 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
+ ` graduated ${si.graduated}; harvest items open ${si.harvestOpen}, recorded ${si.harvestRecorded}`,
284
+ ` first-pass merge rate: this window ${r(si.firstPass.window)}, previous window ${r(si.firstPass.previous)}`);
285
+ }
286
+ }
246
287
  return L.join('\n');
247
288
  }
248
289
 
@@ -256,4 +297,4 @@ function main(argv, out = console.log, opts = {}) {
256
297
  }
257
298
 
258
299
  if (require.main === module) process.exit(main(process.argv.slice(2)));
259
- module.exports = { main, collect, peerStats, dispatchStats, fieldStats, alayaStats, render };
300
+ module.exports = { main, collect, peerStats, dispatchStats, git, fieldStats, alayaStats, render };
@@ -164,10 +164,10 @@ test('alaya: bija counts, activations, exported and seed imports in window; miss
164
164
  test('main: --json parses and all four sections print; bad --days exits 2', () => {
165
165
  const lines = [];
166
166
  assert.strictEqual(S.main(['--json'], (m) => lines.push(m), { home: path.join(tmp, 'home2'), cwd: path.join(tmp, 'repo'), now: NOW }), 0);
167
- assert.deepStrictEqual(Object.keys(JSON.parse(lines[0])), ['days', 'peer', 'dispatch', 'fieldReports', 'alaya']);
167
+ assert.deepStrictEqual(Object.keys(JSON.parse(lines[0])), ['days', 'peer', 'dispatch', 'fieldReports', 'alaya', 'lessons', 'selfImprovement']);
168
168
  const text = [];
169
169
  S.main([], (m) => text.push(m), { home: path.join(tmp, 'nohome'), cwd: path.join(tmp, 'nogit'), now: NOW });
170
- for (const h of ['Peer delivery', 'Dispatch first-pass merge rate', 'Field report -> release latency', 'Alaya']) assert.ok(text[0].includes(h), h);
170
+ for (const h of ['Peer delivery', 'Dispatch first-pass merge rate', 'Field report -> release latency', 'Alaya', 'Lessons', 'Self-improvement']) assert.ok(text[0].includes(h), h);
171
171
  assert.ok(!/\b0%/.test(text[0]) && text[0].includes('no data'));
172
172
  assert.strictEqual(S.main(['--days', 'x'], () => {}), 2);
173
173
  });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * TASK_2420 — runtime/config-only completion and truthful outcome reporting.
3
+ *
4
+ * Drives the REAL task-api.js CLI (`complete`) against a temp git repo with a temp
5
+ * .kenaz/tasks store. Never touches ~/.kenaz, ~/.claude or ~/.codex.
6
+ */
7
+ 'use strict';
8
+
9
+ const test = require('node:test');
10
+ const assert = require('node:assert/strict');
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+ const path = require('path');
14
+ const { execSync, spawnSync } = require('child_process');
15
+
16
+ const ROOT = path.resolve(__dirname, '..', '..', '..');
17
+ const TASK_API = path.join(ROOT, '.collaboration', 'core', 'task-api.js');
18
+ const { summarizePostComplete, spawnFailureCause } = require('../post-complete');
19
+
20
+ function git(cmd, cwd) {
21
+ return execSync(cmd, { cwd, encoding: 'utf8', timeout: 20000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
22
+ }
23
+
24
+ function setup(taskId, scopePaths = []) {
25
+ const tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tn2420-')));
26
+ const repo = path.join(tmp, 'repo');
27
+ fs.mkdirSync(repo);
28
+ git('git init -b userwork', repo);
29
+ git('git config user.email t@t.com', repo);
30
+ git('git config user.name Test', repo);
31
+ git('git config commit.gpgsign false', repo);
32
+ fs.writeFileSync(path.join(repo, 'README.md'), 'init\n');
33
+ fs.writeFileSync(path.join(repo, '.gitignore'), '.kenaz/\n');
34
+ git('git add README.md .gitignore', repo);
35
+ git('git commit --no-verify -m init', repo);
36
+ const dir = path.join(repo, '.kenaz', 'tasks', 'active');
37
+ fs.mkdirSync(dir, { recursive: true });
38
+ fs.writeFileSync(path.join(dir, `${taskId}.yaml`), [
39
+ `id: "${taskId}"`, 'title: "runtime repair"', 'status: in_progress', 'priority: P2',
40
+ scopePaths.length ? `scope_paths:\n${scopePaths.map(p => ` - "${p}"`).join('\n')}` : 'scope_paths: []', '',
41
+ ].join('\n'));
42
+ return { tmp, repo };
43
+ }
44
+
45
+ function complete(repo, taskId, extra = []) {
46
+ return spawnSync('node', [TASK_API, '--project-path', repo, 'complete', taskId, '--skip-archive', ...extra], {
47
+ cwd: ROOT, encoding: 'utf8', timeout: 120000, windowsHide: true,
48
+ env: { ...process.env, KENAZ_INTEGRATION_QUEUE: '' },
49
+ });
50
+ }
51
+
52
+ const where = (repo, taskId) => ['done', 'active', 'blocked']
53
+ .filter(d => fs.existsSync(path.join(repo, '.kenaz', 'tasks', d, `${taskId}.yaml`)));
54
+
55
+ test('TASK_2420: no-branch task with --no-source completes; git merge/verify reported as skipped', () => {
56
+ const taskId = 'TASK_24201';
57
+ const { tmp, repo } = setup(taskId);
58
+ try {
59
+ const r = complete(repo, taskId, ['--no-source']);
60
+ const out = `${r.stdout}\n${r.stderr}`;
61
+ assert.equal(r.status, 0, out);
62
+ assert.match(r.stdout, /任務已完成/);
63
+ assert.match(out, /no branch: nothing to integrate/);
64
+ assert.doesNotMatch(out, /Git merge failed/);
65
+ assert.deepEqual(where(repo, taskId), ['done']);
66
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
67
+ });
68
+
69
+ test('TASK_2420: --no-source is refused when the task branch has unmerged commits (work is not silently dropped)', () => {
70
+ const taskId = 'TASK_24202';
71
+ const { tmp, repo } = setup(taskId);
72
+ try {
73
+ git(`git checkout -b task/${taskId.toLowerCase()}`, repo);
74
+ fs.writeFileSync(path.join(repo, 'a.txt'), 'real work\n');
75
+ git('git add a.txt', repo);
76
+ git('git commit --no-verify -m work', repo);
77
+ git('git checkout userwork', repo);
78
+ const r = complete(repo, taskId, ['--no-source']);
79
+ assert.notEqual(r.status, 0, 'must not report success');
80
+ assert.doesNotMatch(r.stdout, /✅ 任務已完成/);
81
+ assert.match(r.stderr, /任務未完成/);
82
+ assert.match(r.stderr, /no_source_but_work_exists/);
83
+ assert.match(r.stderr, /exists with commits not on userwork/);
84
+ assert.ok(!where(repo, taskId).includes('done'), `card must not stay in done/: ${where(repo, taskId)}`);
85
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
86
+ });
87
+
88
+ test('TASK_2420: branch task whose merge fails is NOT reported as plain success and the cause is shown', () => {
89
+ const taskId = 'TASK_24203';
90
+ const { tmp, repo } = setup(taskId);
91
+ try {
92
+ // Conflicting edits: branch and userwork both change a.txt differently.
93
+ fs.writeFileSync(path.join(repo, 'a.txt'), 'base\n');
94
+ git('git add a.txt', repo); git('git commit --no-verify -m base', repo);
95
+ git(`git checkout -b task/${taskId.toLowerCase()}`, repo);
96
+ fs.writeFileSync(path.join(repo, 'a.txt'), 'branch side\n');
97
+ git('git commit --no-verify -am branch', repo);
98
+ git('git checkout userwork', repo);
99
+ fs.writeFileSync(path.join(repo, 'a.txt'), 'user side\n');
100
+ git('git commit --no-verify -am user', repo);
101
+ const r = complete(repo, taskId);
102
+ assert.notEqual(r.status, 0, `${r.stdout}\n${r.stderr}`);
103
+ assert.doesNotMatch(r.stdout, /✅ 任務已完成/);
104
+ assert.match(r.stderr, /任務未完成/);
105
+ assert.match(r.stderr, /git: failed — \S+/);
106
+ assert.ok(!where(repo, taskId).includes('done'), `card must not stay in done/: ${where(repo, taskId)}`);
107
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
108
+ });
109
+
110
+ test('TASK_2420: summarize — move/reconcile failures carry their real cause and card is flagged unreconciled', () => {
111
+ const s = summarizePostComplete({
112
+ git: { merged: false, taskBranch: 'task/x', failure_reason: 'merge_conflict', mergeErrorText: 'CONFLICT in a.txt', blockError: 'EBUSY: resource busy' },
113
+ reconcileError: 'ETIMEDOUT: spawnSync node ETIMEDOUT',
114
+ });
115
+ assert.equal(s.outcome, 'not_completed');
116
+ assert.equal(s.cardState, 'done_unreconciled');
117
+ const txt = JSON.stringify(s.steps);
118
+ assert.match(txt, /EBUSY: resource busy/);
119
+ assert.match(txt, /ETIMEDOUT/);
120
+ assert.match(txt, /CONFLICT in a\.txt/);
121
+ assert.doesNotMatch(txt, /unknown error/i);
122
+ });
123
+
124
+ test('TASK_2420: spawnFailureCause never returns "unknown error"', () => {
125
+ assert.match(spawnFailureCause({ status: null, stderr: '', stdout: '', error: Object.assign(new Error('spawnSync node ETIMEDOUT'), { code: 'ETIMEDOUT' }) }), /ETIMEDOUT/);
126
+ assert.match(spawnFailureCause({ status: null, signal: 'SIGTERM', stderr: '', stdout: '' }), /SIGTERM/);
127
+ assert.match(spawnFailureCause({ status: 3, stderr: '', stdout: '' }), /code 3/);
128
+ assert.equal(spawnFailureCause({ status: 1, stderr: 'boom\nmore' }), 'boom');
129
+ });