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
@@ -216,6 +216,9 @@ function getTaskSync() {
216
216
  return _taskSync;
217
217
  }
218
218
 
219
+ // TASK_2421: shared-room card replicas (relay) -- pure helper module, safe to require eagerly.
220
+ const taskReplica = require('./task-replica');
221
+
219
222
  // TASK_2142 R2: Task Pool status push (lazy loaded, graceful degradation --
220
223
  // a sub-project checkout without .formation/ must not break moveTask).
221
224
  // Overridable via setPoolStatusPush() so tests can inject a fake/spy
@@ -487,6 +490,11 @@ function emitEmptyListDiagnostics(tasksDir) {
487
490
  } catch { /* never break list */ }
488
491
  }
489
492
 
493
+ /** TASK_2421: is this api's project in an active shared room (relay-leased ids)? */
494
+ function taskReplicaIsShared(api) {
495
+ return taskReplica.isSharedRoom(api._loadYggdrasilConfigSync());
496
+ }
497
+
490
498
  class TaskAPI {
491
499
  /**
492
500
  * @param {Object} config
@@ -1568,6 +1576,14 @@ class TaskAPI {
1568
1576
  // { level_1: string[], level_2: [{endpoint, method, expect}], level_3: {scenario, generates} }
1569
1577
  if (taskData.verification) task.verification = taskData.verification;
1570
1578
  if (idAllocStamp) task.id_alloc = idAllocStamp;
1579
+ // TASK_2421: plan-document id (e.g. 791-17) that maps onto this TASK id
1580
+ if (taskData.alias && String(taskData.alias).trim()) {
1581
+ task.alias = String(taskData.alias).trim();
1582
+ const dup = await this._findAliasOwner(task.alias);
1583
+ if (dup) console.warn(`⚠️ alias "${task.alias}" is already used by ${dup} -- alias lookups resolve to the first match`);
1584
+ }
1585
+ const replicaCtx = this._replicaCtx();
1586
+ if (replicaCtx) task.shared_replica = true; // marker: later status changes keep the relay replica current
1571
1587
 
1572
1588
  // TASK_1110: parallel scope overlap warning (non-blocking).
1573
1589
  // When two tasks declare overlapping scope_paths / file references in
@@ -1604,16 +1620,146 @@ class TaskAPI {
1604
1620
  task._file_path = filePath;
1605
1621
  task._status_dir = 'backlog';
1606
1622
 
1623
+ // TASK_2421: id came from the local counter inside a shared room -> loud warning.
1624
+ if (idAllocStamp && taskReplicaIsShared(this)) {
1625
+ const w = `${taskId} was allocated locally because the relay is unreachable -- this id may collide with one allocated by another node in the shared room. Re-check before pushing, and rename the card if another node reports the same id.`;
1626
+ task.warnings = [...(task.warnings || []), w];
1627
+ console.warn(`⚠️ ID COLLISION RISK: ${w}`);
1628
+ }
1629
+
1630
+ // TASK_2421: publish a read-only replica to the shared room (best-effort).
1631
+ if (replicaCtx) {
1632
+ const r = await taskReplica.publishReplica(replicaCtx, task, 'backlog');
1633
+ task.replica_published = r.ok;
1634
+ if (!r.ok) {
1635
+ const w = `card replica not published to the shared room (${r.error}); other nodes cannot read ${taskId} until a later update re-publishes it`;
1636
+ task.warnings = [...(task.warnings || []), w];
1637
+ console.warn(`⚠️ ${w}`);
1638
+ }
1639
+ }
1640
+
1607
1641
  return task;
1608
1642
  }
1609
1643
 
1644
+ // ---- TASK_2421: shared-room replicas, alias, import ----
1645
+
1646
+ /** Context for relay replica publishing, or null when not in a shared room / opted out. */
1647
+ _replicaCtx() {
1648
+ const yggConfig = this._loadYggdrasilConfigSync();
1649
+ if (!taskReplica.shouldShareCards(yggConfig)) return null;
1650
+ return this._buildReplicaCtx(yggConfig);
1651
+ }
1652
+
1653
+ /** Read-side context: any shared room (share_cards only gates publishing). */
1654
+ _replicaReadCtx() {
1655
+ const yggConfig = this._loadYggdrasilConfigSync();
1656
+ if (!taskReplica.isSharedRoom(yggConfig)) return null;
1657
+ return this._buildReplicaCtx(yggConfig);
1658
+ }
1659
+
1660
+ _buildReplicaCtx(yggConfig) {
1661
+ return {
1662
+ yggConfig,
1663
+ key: this._readPublishableKey(),
1664
+ projectRoot: this._resolveProjectRoot(),
1665
+ node: taskReplica.nodeOf(yggConfig, this._getGitUser().name),
1666
+ toYAML,
1667
+ };
1668
+ }
1669
+
1670
+ /** Keep the relay replica current (status + content). Never throws. */
1671
+ async _syncReplica(task, statusDir) {
1672
+ try {
1673
+ if (!task || !task.shared_replica) return;
1674
+ const ctx = this._replicaCtx();
1675
+ if (!ctx) return;
1676
+ const r = await taskReplica.syncReplica(ctx, task, statusDir);
1677
+ // Row missing (create-time publish failed, or retention removed it): re-publish.
1678
+ if (!r.ok) await taskReplica.publishReplica(ctx, task, statusDir || task._status_dir || 'backlog');
1679
+ } catch { /* best-effort */ }
1680
+ }
1681
+
1682
+ async _findAliasOwner(alias) {
1683
+ try {
1684
+ const want = String(alias).toLowerCase();
1685
+ const all = await this.getAllTasks({ includeArchived: true });
1686
+ const hit = all.tasks.find(t => t.alias && String(t.alias).toLowerCase() === want);
1687
+ return hit ? hit.id : null;
1688
+ } catch { return null; }
1689
+ }
1690
+
1691
+ /** Map an alias (e.g. "791-17") to a TASK id; a TASK id or unknown input is returned unchanged. */
1692
+ async resolveTaskRef(ref) {
1693
+ if (!ref || this.validateTaskID(ref)) return ref;
1694
+ return (await this._findAliasOwner(ref)) || ref;
1695
+ }
1696
+
1697
+ /** Relay lookup by id or alias. Returns {found, row, replica}; never throws. */
1698
+ async lookupReplica(ref) {
1699
+ const ctx = this._replicaReadCtx();
1700
+ if (!ctx) return { found: false };
1701
+ return taskReplica.fetchReplica(ctx, ref, parseYAML);
1702
+ }
1703
+
1704
+ /** Error for a command on an id missing locally; names the replica owner when the relay has the card. */
1705
+ async _missingTaskError(taskId) {
1706
+ const r = await this.lookupReplica(taskId);
1707
+ if (r.found) {
1708
+ const e = new Error(`任務不存在於本機: ${taskId} -- ${taskReplica.replicaNotice(taskId, r.row, r.replica)}. ` +
1709
+ `This node cannot modify the owner's card.`);
1710
+ e.replica = r;
1711
+ return e;
1712
+ }
1713
+ return new Error(`任務不存在: ${taskId}`);
1714
+ }
1715
+
1716
+ /** Import a relay replica as a local card marked with its origin node. */
1717
+ async pullTask(taskId) {
1718
+ await this._ensureStatusDirs();
1719
+ const local = await this.getTask(taskId).catch(() => null);
1720
+ if (local) throw new Error(`${taskId} already exists locally (${local._file_path}); nothing to import`);
1721
+ const r = await this.lookupReplica(taskId);
1722
+ if (!r.found) {
1723
+ throw new Error(r.error ? `relay lookup failed: ${r.error}` : `任務不存在: ${taskId} (not among this shared room's replicas either)`);
1724
+ }
1725
+ const rep = r.replica || {};
1726
+ const gitUser = this._getGitUser();
1727
+ const origin = rep.origin_node || (r.row && r.row.created_by) || 'unknown';
1728
+ const card = {
1729
+ id: r.row.task_id,
1730
+ hash: randomUUID(),
1731
+ title: rep.title || r.row.title || '未命名任務',
1732
+ description: rep.description || r.row.description || '',
1733
+ priority: rep.priority || r.row.priority || 'P2',
1734
+ status: 'pending',
1735
+ role: rep.role || 'developer',
1736
+ tags: [],
1737
+ dependencies: [],
1738
+ scope_paths: [],
1739
+ requirements: toTextArray(rep.requirements),
1740
+ acceptance_criteria: toTextArray(rep.acceptance_criteria),
1741
+ technical_notes: [],
1742
+ created_by: rep.created_by || { name: origin, git_user: origin, timestamp: new Date().toISOString() },
1743
+ executed_by: null,
1744
+ origin_node: origin,
1745
+ imported_from_replica: { relay_status: r.row.status, imported_by: gitUser.name, imported_at: new Date().toISOString() },
1746
+ };
1747
+ if (rep.alias) card.alias = rep.alias;
1748
+ const filePath = path.join(this.tasksDir, 'backlog', `${card.id}_${gitUser.git_user}.yaml`);
1749
+ await fs.writeFile(filePath, toYAML(card));
1750
+ await this._verifyPersisted(filePath, card);
1751
+ card._file_path = filePath;
1752
+ card._status_dir = 'backlog';
1753
+ return card;
1754
+ }
1755
+
1610
1756
  /**
1611
1757
  * 更新任務
1612
1758
  */
1613
1759
  async updateTask(taskId, updates) {
1614
1760
  const task = await this.getTask(taskId);
1615
1761
  if (!task) {
1616
- throw new Error(`任務不存在: ${taskId}`);
1762
+ throw await this._missingTaskError(taskId);
1617
1763
  }
1618
1764
 
1619
1765
  // TASK_1771: coerce array-typed fields so a raw-string update can never
@@ -1667,6 +1813,8 @@ class TaskAPI {
1667
1813
  // 垃圾值只污染回傳值,不落 YAML(toYAML 過濾底線鍵 + 寫檔前已 delete)。
1668
1814
  updatedTask._status_dir = this._resolveStatusDirName(filePath);
1669
1815
 
1816
+ await this._syncReplica(updatedTask, undefined); // TASK_2421: content refresh (title/alias/...)
1817
+
1670
1818
  return updatedTask;
1671
1819
  }
1672
1820
 
@@ -1682,7 +1830,7 @@ class TaskAPI {
1682
1830
  if (!task) {
1683
1831
  task = await this.getTask(taskId);
1684
1832
  if (!task) {
1685
- throw new Error(`任務不存在: ${taskId}`);
1833
+ throw await this._missingTaskError(taskId);
1686
1834
  }
1687
1835
  }
1688
1836
 
@@ -1844,6 +1992,9 @@ class TaskAPI {
1844
1992
  projectPath: this.projectPath,
1845
1993
  }).catch(() => {});
1846
1994
 
1995
+ // TASK_2421: keep the shared-room replica's status current (best-effort, bounded by the relay timeout).
1996
+ await this._syncReplica(updatedTask, targetDir);
1997
+
1847
1998
  return updatedTask;
1848
1999
  }
1849
2000
 
@@ -1874,7 +2025,7 @@ class TaskAPI {
1874
2025
 
1875
2026
  const task = await this.getTask(taskId);
1876
2027
  if (!task) {
1877
- throw new Error('任務不存在: ' + taskId);
2028
+ throw await this._missingTaskError(taskId);
1878
2029
  }
1879
2030
 
1880
2031
  // M1: acquire claim marker (wx atomic — no TOCTOU)
@@ -1975,7 +2126,7 @@ class TaskAPI {
1975
2126
 
1976
2127
  const task = await this.getTask(taskId);
1977
2128
  if (!task) {
1978
- throw new Error(`任務不存在: ${taskId}`);
2129
+ throw await this._missingTaskError(taskId);
1979
2130
  }
1980
2131
 
1981
2132
  // TASK_2233: backlog → done is not an allowed transition; say what to do instead
@@ -2016,6 +2167,7 @@ class TaskAPI {
2016
2167
  }
2017
2168
 
2018
2169
  const postCompleteRuns = !options.skipPostComplete;
2170
+ let postCompleteSummary = null;
2019
2171
  const result = await this.moveTask(taskId, 'done', updatedTask);
2020
2172
 
2021
2173
  try {
@@ -2040,7 +2192,7 @@ class TaskAPI {
2040
2192
  try {
2041
2193
  const { postComplete } = require('../../.formation/tools/post-complete.js');
2042
2194
  console.log(`\n🔗 自動執行 post-complete for ${taskId}...\n`);
2043
- await postComplete({
2195
+ const pc = await postComplete({
2044
2196
  taskId,
2045
2197
  keep: options.keep || 10,
2046
2198
  skipArchive: options.skipArchive || false,
@@ -2048,13 +2200,19 @@ class TaskAPI {
2048
2200
  dryRun: options.dryRun || false,
2049
2201
  message: options.message,
2050
2202
  projectPath: this.projectPath,
2203
+ noSource: options.noSource || false,
2051
2204
  });
2205
+ postCompleteSummary = (pc && pc.summary) || null;
2052
2206
  } catch (error) {
2053
2207
  console.error(`⚠️ post-complete 失敗: ${error.message}`);
2054
- console.error(' 任務已完成,但 post-complete 需要手動執行');
2208
+ // TASK_2420: surface the failure to the caller instead of a bare "completed".
2209
+ postCompleteSummary = { outcome: 'not_completed', cardState: 'done_unreconciled', warnings: [],
2210
+ steps: [{ step: 'post_complete', status: 'failed', reason: error.message }] };
2055
2211
  }
2056
2212
  }
2057
2213
 
2214
+ // TASK_2420: non-enumerable so it never leaks into YAML / JSON serialisation of the task.
2215
+ Object.defineProperty(result, 'postComplete', { value: postCompleteSummary, enumerable: false });
2058
2216
  return result;
2059
2217
  }
2060
2218
 
@@ -2111,7 +2269,8 @@ class TaskAPI {
2111
2269
  const desc = (t.description || '').toLowerCase();
2112
2270
  const id = (t.id || '').toLowerCase();
2113
2271
  const tags = (t.tags || []).join(' ').toLowerCase();
2114
- return title.includes(searchLower) || desc.includes(searchLower) || id.includes(searchLower) || tags.includes(searchLower);
2272
+ const alias = String(t.alias || '').toLowerCase(); // TASK_2421
2273
+ return title.includes(searchLower) || desc.includes(searchLower) || id.includes(searchLower) || tags.includes(searchLower) || (alias !== '' && alias.includes(searchLower));
2115
2274
  });
2116
2275
  }
2117
2276
 
@@ -2488,6 +2647,7 @@ Commands:
2488
2647
  node task-api.js update TASK_ID [--title T] [--description D] [--priority P] [--role R] [--technical-notes N]...
2489
2648
  陣列欄位(tags/dependencies/scope-paths/requirements/acceptance-criteria/technical-notes 等)
2490
2649
  預設為整組取代(會回顯被移除的既有項目);加 --append 改為追加到既有項目之後(與既有重複的項目會略過並回顯)
2650
+ node task-api.js pull TASK_ID 匯入共享 room 的唯讀 replica 為本機卡片(標記 origin_node;TASK_2421)
2491
2651
  node task-api.js claim TASK_ID
2492
2652
  node task-api.js complete TASK_ID [--actual-hours HOURS]
2493
2653
  node task-api.js reconcile [--remote origin] [--base main] [--apply] [--worktrees] 關閉分支已併入 base 的 active 任務(預設 dry-run)
@@ -2541,6 +2701,7 @@ Create 選項:
2541
2701
  --args-file PATH UTF-8 JSON 物件;支援 camelCase / snake_case,CLI 旗標優先
2542
2702
  requirements / acceptance_criteria / technical_notes 使用 JSON 陣列
2543
2703
  一般品質門檻:requirements >= 3,acceptance_criteria >= 3,technical_notes >= 2
2704
+ --alias PLAN_ID 計畫文件編號(如 791-17),get/update/move/complete/search 皆可用(TASK_2421)
2544
2705
  --estimated-hours N 預估工時(小時)
2545
2706
  --branch NAME Git 分支
2546
2707
  --notes TEXT 備註
@@ -2577,6 +2738,11 @@ Create 選項:
2577
2738
 
2578
2739
  const command = args[0];
2579
2740
 
2741
+ // TASK_2421: a plan-document alias (e.g. 791-17) works anywhere a TASK id does.
2742
+ if (['get', 'update', 'move', 'complete', 'claim', 'pull'].includes(command) && args[1] && !args[1].startsWith('--')) {
2743
+ args[1] = await api.resolveTaskRef(args[1]);
2744
+ }
2745
+
2580
2746
  if (command === 'reconcile') {
2581
2747
  // TASK_2404: close active cards whose branches already landed on <remote>/<base>
2582
2748
  const argOf = (n, d) => { const i = args.indexOf(n); return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : d; };
@@ -2846,14 +3012,32 @@ Create 選項:
2846
3012
  console.error('錯誤: 缺少任務 ID');
2847
3013
  process.exit(1);
2848
3014
  }
2849
- const task = await api.getTask(taskId);
3015
+ // TASK_2421: a non-TASK ref (relay-only alias) skips the local lookup (getTask throws on it)
3016
+ const task = api.validateTaskID(taskId) ? await api.getTask(taskId) : null;
2850
3017
  if (task) {
2851
3018
  console.log(JSON.stringify(task, null, 2));
2852
3019
  } else {
2853
- console.error(`任務不存在: ${taskId}`);
2854
- process.exit(1);
3020
+ // TASK_2421: id unknown locally -> look for a read-only replica in the shared room
3021
+ const rep = await api.lookupReplica(taskId);
3022
+ if (rep.found) {
3023
+ const out = { ...rep.replica, id: rep.row.task_id, relay_status: rep.row.status, read_only_replica: true,
3024
+ replica_notice: require('./task-replica').replicaNotice(taskId, rep.row, rep.replica) };
3025
+ console.log(JSON.stringify(out, null, 2));
3026
+ console.error(`ℹ️ ${taskId}: ${out.replica_notice}`);
3027
+ } else {
3028
+ console.error(`任務不存在: ${taskId}`);
3029
+ if (rep.error) console.error(` (shared-room replica lookup failed: ${rep.error})`);
3030
+ process.exit(1);
3031
+ }
2855
3032
  }
2856
3033
 
3034
+ } else if (command === 'pull') {
3035
+ // TASK_2421: import a shared-room replica as a local card marked with its origin node
3036
+ const taskId = args[1];
3037
+ if (!taskId) { console.error('錯誤: 缺少任務 ID'); process.exit(1); }
3038
+ const card = await api.pullTask(taskId);
3039
+ console.log(`✅ 已匯入: ${card.id} (origin node: ${card.origin_node}) -> ${card._file_path}`);
3040
+
2857
3041
  } else if (command === 'create') {
2858
3042
  // TASK_1599: Windows CreateProcess caps command line at 32767 UTF-16 chars.
2859
3043
  // Detailed task cards (long description + many requirements) easily exceed this
@@ -2951,6 +3135,7 @@ Create 選項:
2951
3135
  // is the documented flag name; it maps onto the pre-existing `source`
2952
3136
  // field that createTask() already promotes onto the task when truthy.
2953
3137
  source: getArg('--source') || getArg('--origin') || fromFile('source') || fromFile('origin') || null,
3138
+ alias: getArg('--alias') || fromFile('alias') || undefined, // TASK_2421
2954
3139
  };
2955
3140
 
2956
3141
  const reqs = getMulti('--requirements');
@@ -2987,6 +3172,9 @@ Create 選項:
2987
3172
  if (task.scope_paths && task.scope_paths.length > 0) console.log(`範圍: ${task.scope_paths.join(', ')}`);
2988
3173
  if (task.estimated_hours != null) console.log(`預估工時: ${task.estimated_hours}h`);
2989
3174
  if (task.branch) console.log(`分支: ${task.branch}`);
3175
+ if (task.alias) console.log(`別名: ${task.alias}`);
3176
+ if (task.replica_published) console.log(`共享: replica published to the shared room (read-only for other nodes)`);
3177
+ for (const w of task.warnings || []) console.log(`⚠️ ${w}`);
2990
3178
 
2991
3179
  } else if (command === 'claim') {
2992
3180
  const taskId = args[1];
@@ -3021,9 +3209,29 @@ Create 選項:
3021
3209
  if (args.includes('--skip-post-complete')) {
3022
3210
  options.skipPostComplete = true;
3023
3211
  }
3212
+ // TASK_2420: runtime/config tasks with no branch — git merge/verify reported as skipped
3213
+ if (args.includes('--no-source')) {
3214
+ options.noSource = true;
3215
+ }
3024
3216
  const task = await api.completeTask(taskId, options);
3025
- console.log(`✅ 任務已完成: ${task.id}`);
3026
- if (task.actual_hours != null) console.log(`實際工時: ${task.actual_hours} 小時`);
3217
+ const pcs = task.postComplete;
3218
+ const stepLines = pcs ? (pcs.steps || []).map(st => ` ${st.status === 'failed' ? '❌' : st.status === 'skipped' ? '⏭' : '✅'} ${st.step}: ${st.status}${st.reason ? ' — ' + st.reason : ''}`) : [];
3219
+ if (pcs && pcs.outcome === 'not_completed') {
3220
+ // The card was NOT delivered: say so, with the real cause, and fail the command.
3221
+ console.error(`❌ 任務未完成: ${task.id} — ${pcs.cardState === 'blocked' ? '已移至 blocked/' : pcs.cardState === 'active' ? '留在 active/' : '卡片仍在 done/ 但交付失敗(需人工處理)'}`);
3222
+ for (const l of stepLines) console.error(l);
3223
+ console.error(` 若此任務沒有 source 變更(runtime/config 修復),請改用: complete ${task.id} --no-source`);
3224
+ process.exitCode = 1;
3225
+ } else {
3226
+ console.log(`✅ 任務已完成${pcs && pcs.outcome === 'completed_with_warnings' ? '(含警告)' : ''}: ${task.id}`);
3227
+ if (pcs && pcs.outcome === 'completed_with_warnings') {
3228
+ for (const l of stepLines) console.log(l);
3229
+ for (const w of pcs.warnings) console.log(` ⚠️ ${w}`);
3230
+ } else {
3231
+ for (const l of stepLines.filter(l => l.includes('⏭'))) console.log(l);
3232
+ }
3233
+ if (task.actual_hours != null) console.log(`實際工時: ${task.actual_hours} 小時`);
3234
+ }
3027
3235
 
3028
3236
  } else if (command === 'move') {
3029
3237
  const taskId = args[1];
@@ -3374,6 +3582,7 @@ Create 選項:
3374
3582
  const reqs = getMulti('--requirements'); if (reqs) updates.requirements = reqs;
3375
3583
  const ac = getMulti('--acceptance-criteria'); if (ac) updates.acceptance_criteria = ac;
3376
3584
  const tn = getMulti('--technical-notes'); if (tn) updates.technical_notes = tn;
3585
+ const aliasArg = getArg('--alias'); if (aliasArg !== null) updates.alias = aliasArg; // TASK_2421
3377
3586
  const mergeLandedArg = getArg('--merge-landed');
3378
3587
  if (mergeLandedArg !== null) updates.merge_landed = (mergeLandedArg === 'true');
3379
3588
 
@@ -0,0 +1,189 @@
1
+ 'use strict';
2
+ /**
3
+ * task-replica.js (TASK_2421)
4
+ *
5
+ * Shared-room card replicas. Task IDs are allocated room-wide via relay leases
6
+ * but card files live only on the creating node (.kenaz/tasks is gitignored), so
7
+ * other nodes got "任務不存在" for ids they saw in git/messages. When the project
8
+ * is in an active shared room, `task-api create` also publishes a READ-ONLY
9
+ * replica of the card to the relay tasks table, and `get/update/move/complete`
10
+ * on an unknown id look that replica up.
11
+ *
12
+ * Why status 'assigned' with NO assigned_to (no relay change needed):
13
+ * - GET /api/tasks defaults to status=pool, so the pool list never shows it
14
+ * - kenaz_pool_pull (inbox) filters assigned_to == this node, so the creator's
15
+ * own inbox does not fill with replicas either
16
+ * - 'assigned' is already a valid, non-pool relay status
17
+ * Residual: relay claim is not status-gated, so an explicit claim by exact id
18
+ * would still succeed; replicas are documented as non-claimable (full_yaml
19
+ * carries `replica: true`).
20
+ *
21
+ * Everything here is best-effort and never throws into a caller: a relay problem
22
+ * must never fail a local create/move.
23
+ */
24
+
25
+ const http = require('http');
26
+ const https = require('https');
27
+ const path = require('path');
28
+
29
+ const TIMEOUT_MS = 3000;
30
+ // local status dir -> relay status. Backlog/pending stays the non-pool 'assigned'.
31
+ const RELAY_STATUS = {
32
+ backlog: 'assigned', pending: 'assigned',
33
+ active: 'in_progress', blocked: 'blocked', done: 'done', cancel: 'cancelled',
34
+ };
35
+ const ALL_STATUSES = 'assigned,in_progress,blocked,done,cancelled,completed,claimed,pool';
36
+
37
+ /** Shared-room condition, identical to generateTaskID's. */
38
+ function isSharedRoom(yggConfig) {
39
+ return !!(yggConfig && yggConfig.enabled !== false &&
40
+ yggConfig.relay_url && String(yggConfig.relay_url).trim() !== '' &&
41
+ yggConfig.shared_task_ids !== false);
42
+ }
43
+
44
+ /** Replica publishing: shared room AND not opted out (`share_cards: false`). */
45
+ function shouldShareCards(yggConfig) {
46
+ return isSharedRoom(yggConfig) && yggConfig.share_cards !== false;
47
+ }
48
+
49
+ function projectOf(yggConfig, projectRoot) {
50
+ return (yggConfig.yggdrasil_project && String(yggConfig.yggdrasil_project).trim()) ||
51
+ path.basename(projectRoot) || 'default';
52
+ }
53
+
54
+ function nodeOf(yggConfig, gitUserName) {
55
+ return (yggConfig.node_name && String(yggConfig.node_name).trim()) || gitUserName || 'unknown';
56
+ }
57
+
58
+ function relayRequest(yggConfig, key, method, pathAndQuery, bodyObj) {
59
+ return new Promise((resolve, reject) => {
60
+ const base = String(yggConfig.relay_url).replace(/^wss:\/\//, 'https://').replace(/^ws:\/\//, 'http://');
61
+ let url;
62
+ try { url = new URL(pathAndQuery, base); } catch { reject(new Error('invalid relay_url')); return; }
63
+ const isHttps = url.protocol === 'https:';
64
+ const body = bodyObj !== undefined ? JSON.stringify(bodyObj) : null;
65
+ let settled = false;
66
+ const req = (isHttps ? https : http).request({
67
+ hostname: url.hostname, port: url.port || (isHttps ? 443 : 80),
68
+ path: url.pathname + url.search, method,
69
+ headers: {
70
+ ...(body ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } : {}),
71
+ ...(key ? { Authorization: `Bearer ${key}` } : {}),
72
+ },
73
+ }, (res) => {
74
+ let data = '';
75
+ res.on('data', (c) => { data += c; });
76
+ res.on('end', () => {
77
+ if (settled) return; settled = true;
78
+ if (res.statusCode < 200 || res.statusCode >= 300) { reject(new Error(`relay HTTP ${res.statusCode}`)); return; }
79
+ try { resolve(data ? JSON.parse(data) : null); } catch (e) { reject(new Error(`relay parse error: ${e.message}`)); }
80
+ });
81
+ });
82
+ req.on('error', (e) => { if (!settled) { settled = true; reject(e); } });
83
+ const timer = setTimeout(() => { if (!settled) { settled = true; req.destroy(); reject(new Error('relay timeout')); } }, TIMEOUT_MS);
84
+ req.on('close', () => clearTimeout(timer));
85
+ if (body) req.write(body);
86
+ req.end();
87
+ });
88
+ }
89
+
90
+ /** Plain-object copy of the card without internal `_` keys. */
91
+ function cleanCard(task) {
92
+ const out = {};
93
+ for (const [k, v] of Object.entries(task || {})) if (!k.startsWith('_')) out[k] = v;
94
+ return out;
95
+ }
96
+
97
+ /**
98
+ * Publish a replica. ctx = { yggConfig, key, projectRoot, node, toYAML }.
99
+ * Returns {ok:true} | {ok:false, error}.
100
+ */
101
+ async function publishReplica(ctx, task, localStatusDir) {
102
+ try {
103
+ const card = cleanCard(task);
104
+ const replicaCard = {
105
+ replica: true,
106
+ origin_node: ctx.node,
107
+ id: card.id, title: card.title, description: card.description,
108
+ priority: card.priority, status: card.status, alias: card.alias || null,
109
+ requirements: card.requirements || [], acceptance_criteria: card.acceptance_criteria || [],
110
+ created_by: card.created_by || null,
111
+ };
112
+ await relayRequest(ctx.yggConfig, ctx.key, 'POST', '/api/tasks', {
113
+ task_id: task.id,
114
+ project: projectOf(ctx.yggConfig, ctx.projectRoot),
115
+ status: RELAY_STATUS[localStatusDir || 'backlog'] || 'assigned',
116
+ priority: card.priority || 'P2',
117
+ title: card.title,
118
+ description: card.description,
119
+ created_by: ctx.node,
120
+ full_yaml: ctx.toYAML(replicaCard),
121
+ });
122
+ return { ok: true };
123
+ } catch (e) {
124
+ return { ok: false, error: e.message };
125
+ }
126
+ }
127
+
128
+ /** Push a status (and refreshed card) to an existing replica row. */
129
+ async function syncReplica(ctx, task, localStatusDir) {
130
+ try {
131
+ const card = cleanCard(task);
132
+ const body = {
133
+ project: projectOf(ctx.yggConfig, ctx.projectRoot),
134
+ title: card.title, description: card.description, priority: card.priority,
135
+ full_yaml: ctx.toYAML({
136
+ replica: true, origin_node: ctx.node, id: card.id, title: card.title,
137
+ description: card.description, priority: card.priority, status: card.status,
138
+ alias: card.alias || null, requirements: card.requirements || [],
139
+ acceptance_criteria: card.acceptance_criteria || [], created_by: card.created_by || null,
140
+ }),
141
+ };
142
+ if (localStatusDir && RELAY_STATUS[localStatusDir]) body.status = RELAY_STATUS[localStatusDir];
143
+ await relayRequest(ctx.yggConfig, ctx.key, 'PATCH',
144
+ `/api/tasks/${encodeURIComponent(task.id)}?project=${encodeURIComponent(body.project)}`, body);
145
+ return { ok: true };
146
+ } catch (e) {
147
+ return { ok: false, error: e.message };
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Look a card up on the relay by TASK id or alias. Returns
153
+ * { found:true, row, replica } | { found:false } | { found:false, error }.
154
+ * `replica` is the parsed full_yaml (or a minimal object from row columns).
155
+ */
156
+ async function fetchReplica(ctx, idOrAlias, parseYAML) {
157
+ try {
158
+ const project = projectOf(ctx.yggConfig, ctx.projectRoot);
159
+ const rows = await relayRequest(ctx.yggConfig, ctx.key, 'GET',
160
+ `/api/tasks?project=${encodeURIComponent(project)}&status=${ALL_STATUSES}&limit=1000&include_blocked=1`);
161
+ const list = Array.isArray(rows) ? rows : [];
162
+ const want = String(idOrAlias).toLowerCase();
163
+ for (const row of list) {
164
+ let card = null;
165
+ try { card = row.full_yaml ? parseYAML(row.full_yaml) : null; } catch { card = null; }
166
+ const alias = card && card.alias ? String(card.alias).toLowerCase() : null;
167
+ if (String(row.task_id).toLowerCase() === want || (alias && alias === want)) {
168
+ return { found: true, row, replica: card && Object.keys(card).length ? card : {
169
+ id: row.task_id, title: row.title, description: row.description, priority: row.priority,
170
+ } };
171
+ }
172
+ }
173
+ return { found: false };
174
+ } catch (e) {
175
+ return { found: false, error: e.message };
176
+ }
177
+ }
178
+
179
+ /** Human-readable "read-only replica" notice. */
180
+ function replicaNotice(taskId, row, replica) {
181
+ const node = (replica && replica.origin_node) || (row && row.created_by) || 'unknown';
182
+ return `read-only replica; card lives on node ${node}. ` +
183
+ `To track it locally: node .collaboration/core/task-api.js pull ${taskId}`;
184
+ }
185
+
186
+ module.exports = {
187
+ isSharedRoom, shouldShareCards, projectOf, nodeOf, relayRequest,
188
+ publishReplica, syncReplica, fetchReplica, replicaNotice, RELAY_STATUS,
189
+ };
@@ -0,0 +1 @@
1
+ {"pid":32452,"taskId":"TASK_2356","acquiredAt":"2026-10-09T10:19:19.033Z"}
@@ -127,10 +127,20 @@ function diagnose() {
127
127
  if (unreg.length) notes.push('Kenaz hooks not registered for: ' + unreg.join(', ') + '. Run: node .formation/bifrost/setup-codex.js');
128
128
  if (untrusted.length) notes.push('Kenaz hooks registered but NOT trusted (' + untrusted.join(', ') + '); codex does not run them. Open codex, type /hooks, and trust the kenaz entries (re-trust after any change to the hook command).');
129
129
  if (trust.UserPromptSubmit.registered && !trust.UserPromptSubmit.pull) notes.push('UserPromptSubmit hook lacks --pull, so independently opened codex sessions receive no peer mail. Remove the kenaz hook blocks from config.toml and re-run setup-codex.js.');
130
- return { trust, delivery_mode: deliveryMode(trust), ok: notes.length === 0, notes };
130
+ const managed = managedDaemon();
131
+ const mode = deliveryMode(trust);
132
+ if (managed && mode === 'pull-on-prompt') notes.push(MANAGED_NOTE);
133
+ return { trust, delivery_mode: mode, managed_daemon: managed, ok: notes.filter((n) => n !== MANAGED_NOTE).length === 0, notes };
131
134
  }
132
135
 
133
- module.exports = { promptText, skillText, installPrompts, installAgentsBlock, removeAgentsBlock, agentsBlock, hookTrust, deliveryMode, diagnose, SHIM_REL, EVENTS };
136
+ // TASK_2419: Codex desktop managed app-server daemon (unix control socket / pid file under CODEX_HOME).
137
+ function managedDaemon(home) {
138
+ const h = home || codexHome();
139
+ return fs.existsSync(path.join(h, 'app-server-control', 'app-server-control.sock')) || fs.existsSync(path.join(h, 'app-server-daemon', 'daemon.pid'));
140
+ }
141
+ const MANAGED_NOTE = 'Codex managed daemon detected: Kenaz has no push channel into it. Peer mail arrives on your next prompt via the UserPromptSubmit --pull hook (delivery_mode pull-on-prompt).';
142
+
143
+ module.exports = { managedDaemon, MANAGED_NOTE, promptText, skillText, installPrompts, installAgentsBlock, removeAgentsBlock, agentsBlock, hookTrust, deliveryMode, diagnose, SHIM_REL, EVENTS };
134
144
 
135
145
  if (require.main === module) {
136
146
  if (process.argv.includes('--install-prompts')) console.log(JSON.stringify(installPrompts()));
@@ -67,3 +67,18 @@ test('global AGENTS.md gets one managed Kenaz block (wish / field report guidanc
67
67
  assert.doesNotMatch(after, /kenaz:begin|kenaz_wish_create/);
68
68
  delete process.env.CODEX_HOME;
69
69
  });
70
+
71
+ test('TASK_2419: managed daemon detected from CODEX_HOME markers; diagnose reports it without failing ok', () => {
72
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cx-md-'));
73
+ assert.strictEqual(D.managedDaemon(home), false); // control: empty home
74
+ fs.mkdirSync(path.join(home, 'app-server-control'));
75
+ fs.writeFileSync(path.join(home, 'app-server-control', 'app-server-control.sock'), '');
76
+ assert.strictEqual(D.managedDaemon(home), true);
77
+ process.env.CODEX_HOME = home;
78
+ fs.writeFileSync(path.join(home, 'config.toml'), cfg(true, true));
79
+ const d = D.diagnose();
80
+ assert.strictEqual(d.managed_daemon, true);
81
+ assert.strictEqual(d.delivery_mode, 'pull-on-prompt');
82
+ assert.ok(d.notes.includes(D.MANAGED_NOTE)); assert.strictEqual(d.ok, d.notes.length === 1); // the managed note alone is informational
83
+ delete process.env.CODEX_HOME;
84
+ });