kenaz 0.3.10 → 0.3.12

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 (131) hide show
  1. package/core/.claude/skills/code_reviewer/SKILL.md +0 -1
  2. package/core/.claude/skills/code_reviewer/patterns.yaml +0 -8
  3. package/core/.claude/skills/dev_agent/SKILL.md +3 -6
  4. package/core/.claude/skills/heimdall/SKILL.md +5 -6
  5. package/core/.claude/skills/huginn/SKILL.md +11 -38
  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 +223 -230
  9. package/core/.collaboration/core/task-api.test.js +1 -138
  10. package/core/.collaboration/core/task-replica.js +189 -0
  11. package/core/.formation/.merge-lock +1 -0
  12. package/core/.formation/bifrost/codex-doctor.js +12 -2
  13. package/core/.formation/bifrost/codex-doctor.test.js +15 -0
  14. package/core/.formation/bifrost/launch.js +2 -2
  15. package/core/.formation/bifrost/launch.test.js +2 -2
  16. package/core/.formation/bifrost/setup-codex.js +84 -16
  17. package/core/.formation/bifrost/shared/ansuz-boot-policy.js +3 -3
  18. package/core/.formation/bifrost/shared/codex-injection.js +3 -17
  19. package/core/.formation/bifrost/shared/consolidate.js +1 -51
  20. package/core/.formation/bifrost/sync.js +4 -5
  21. package/core/.formation/tools/__tests__/subagent-bifrost-guard.test.js +1 -1
  22. package/core/.formation/tools/dispatch.js +2 -2
  23. package/core/.formation/tools/kenaz-stats.js +1 -1
  24. package/core/.formation/tools/mcp-allowlist.test.js +5 -3
  25. package/core/.formation/tools/post-complete/bounded-http.js +1 -40
  26. package/core/.formation/tools/post-complete/bounded-http.test.js +2 -89
  27. package/core/.formation/tools/post-complete/cognitive.js +2 -119
  28. package/core/.formation/tools/post-complete/task2420.test.js +129 -0
  29. package/core/.formation/tools/post-complete/testing.js +3 -21
  30. package/core/.formation/tools/post-complete.js +131 -436
  31. package/core/.formation/tools/post-complete.test.js +0 -135
  32. package/core/.formation/tools/route-audit.test.js +8 -20
  33. package/core/.kenaz/specialist/memory_guard_fired.json +1 -1
  34. package/core/.kenaz/specialist/memory_guard_trigger_cache.json +1 -1
  35. package/core/.rule/INDEX.yaml +3 -3
  36. package/core/.rule/general/ddx-synapse-stack.yaml +11 -11
  37. package/core/.rule/general/specialist-identity-contract.yaml +5 -5
  38. package/core/.rule/general/synapse-code-intelligence.yaml +25 -111
  39. package/core/.rule/general/worktree-isolation.yaml +2 -2
  40. package/core/.rule/pipeline/task-execution.yaml +6 -9
  41. package/core/.specialist/Amelia/README.md +5 -5
  42. package/core/.specialist/Amelia/capability.yaml +10 -18
  43. package/core/.specialist/Amelia/knowledge.yaml +15 -66
  44. package/core/.specialist/Amelia/workflow.yaml +12 -21
  45. package/core/.specialist/Ansuz/capability.yaml +12 -87
  46. package/core/.specialist/Ansuz/concepts/autonomous_agenda.yaml +1 -1
  47. package/core/.specialist/Ansuz/concepts/autonomy_contract.yaml +2 -3
  48. package/core/.specialist/Ansuz/concepts/capability_pointers.yaml +1 -3
  49. package/core/.specialist/Ansuz/concepts/cognitive_fluency_contract.yaml +8 -21
  50. package/core/.specialist/Ansuz/concepts/cognitive_systems_overview.yaml +0 -4
  51. package/core/.specialist/Ansuz/concepts/cognitive_systems_turn_context.yaml +1 -1
  52. package/core/.specialist/Ansuz/concepts/completion_tracking.yaml +0 -1
  53. package/core/.specialist/Ansuz/concepts/deep_knowledge_pointers.yaml +0 -3
  54. package/core/.specialist/Ansuz/concepts/evolvable_guardrails.yaml +3 -3
  55. package/core/.specialist/Ansuz/concepts/fatigue_handoff.yaml +1 -7
  56. package/core/.specialist/Ansuz/concepts/guardrails_immutable.yaml +3 -4
  57. package/core/.specialist/Ansuz/concepts/identity.yaml +1 -2
  58. package/core/.specialist/Ansuz/concepts/phase9_verification.yaml +0 -1
  59. package/core/.specialist/Ansuz/concepts/phase_protocol.yaml +3 -4
  60. package/core/.specialist/Ansuz/core.yaml +3 -6
  61. package/core/.specialist/Ansuz/elixir.yaml +6 -6
  62. package/core/.specialist/Ansuz/exo_cortex_tools.yaml +5 -9
  63. package/core/.specialist/Ansuz/guards/blind_spot_guards.yaml +2 -2
  64. package/core/.specialist/Ansuz/guards/lesson_guards.yaml +6 -19
  65. package/core/.specialist/Ansuz/identity_contract.yaml +4 -4
  66. package/core/.specialist/Ansuz/knowledge.yaml +13 -40
  67. package/core/.specialist/Ansuz/modules/ddx.yaml +2 -2
  68. package/core/.specialist/Ansuz/modules/prophecy.yaml +19 -19
  69. package/core/.specialist/Ansuz/modules/pulse_empty.yaml +6 -17
  70. package/core/.specialist/Ansuz/tools/ansuz-handoff-snapshot.js +3 -102
  71. package/core/.specialist/Ansuz/tools/ansuz-sdk-runner.js +2 -44
  72. package/core/.specialist/Ansuz/workflow.yaml +32 -76
  73. package/core/.specialist/Forseti/core.yaml +2 -7
  74. package/core/.specialist/Forseti/rule.yaml +1 -1
  75. package/core/.specialist/Heimdall/SKILL.md +2 -3
  76. package/core/.specialist/Heimdall/capability.yaml +22 -45
  77. package/core/.specialist/Heimdall/core.yaml +24 -126
  78. package/core/.specialist/Heimdall/principles.yaml +8 -11
  79. package/core/.specialist/Heimdall/rule.yaml +2 -2
  80. package/core/.specialist/Huginn/capability.yaml +31 -52
  81. package/core/.specialist/Huginn/core.yaml +43 -123
  82. package/core/.specialist/Huginn/principles.yaml +5 -5
  83. package/core/.specialist/Huginn/rule.yaml +3 -9
  84. package/core/.specialist/REFLECTION_TO_CONTROL.md +3 -4
  85. package/core/.specialist/shared/guard_schema.json +1 -1
  86. package/core/.specialist/shared/principles.yaml +6 -6
  87. package/core/.specialist/tools/__tests__/rea-detect.test.js +10 -0
  88. package/core/.specialist/tools/phase-gate.js +10 -230
  89. package/core/.specialist/tools/phase-gate.test.js +2 -2
  90. package/core/.specialist/tools/prompt-files.test.js +1 -1
  91. package/core/.specialist/tools/rea-detect.js +6 -2
  92. package/core/.specialist/tools/session.js +2 -89
  93. package/core/.synapse/memory-shared.json.sync +1 -0
  94. package/core/.system/memory/README.md +3 -0
  95. package/core/CLAUDE.md +6 -7
  96. package/core/KENAZ_CORE_VERSION +1 -1
  97. package/core/dev/plans/CLI_FIRST_INSTALL_FLOW.md +75 -83
  98. package/core/dev/specs/peer/CODEX_MANAGED_DAEMON.md +54 -0
  99. package/core/dev/specs/peer/setup-codex-hooks.test.js +38 -0
  100. package/core/dev/spikes/task2145-evidence-20261004/backend-log-excerpt.txt +56 -0
  101. package/core/dev/spikes/task2145-evidence-20261004/graph.shadow +0 -0
  102. package/core/dev/spikes/task2145-evidence-20261004/graph.wal +0 -0
  103. package/core/dev/spikes/task2145-evidence-20261004/graph_corrupt_suspect.marker +1 -0
  104. package/core/plugins/kenaz/INVENTORY.md +1 -1
  105. package/core/plugins/kenaz/scripts/kenaz.test.js +70 -1
  106. package/core/plugins/kenaz/scripts/ygg.js +45 -4
  107. package/core/plugins/kenaz/skills/code_reviewer/SKILL.md +0 -1
  108. package/core/plugins/kenaz/skills/code_reviewer/patterns.yaml +0 -8
  109. package/core/plugins/kenaz/skills/dev_agent/SKILL.md +3 -6
  110. package/lib/cli.js +17 -9
  111. package/lib/cli.test.js +10 -0
  112. package/lib/dashboard.test.js +3 -3
  113. package/package.json +6 -6
  114. package/vendor/install-kenaz-cli.js +167 -16
  115. package/core/.claude/hooks/handoff-snapshot-on-decision.js +0 -188
  116. package/core/.claude/skills/synapse_maintain/SKILL.md +0 -84
  117. package/core/.collaboration/core/task-2264-single-after-capture.test.js +0 -61
  118. package/core/.formation/tools/alaya-gate.js +0 -198
  119. package/core/.formation/tools/post-complete/health.js +0 -464
  120. package/core/.formation/tools/post-complete/task2038.test.js +0 -246
  121. package/core/.formation/tools/post-complete/task2341.test.js +0 -74
  122. package/core/.rule/general/cortex-tools.yaml +0 -181
  123. package/core/.rule/general/memory-language.yaml +0 -67
  124. package/core/.rule/general/memory-source-priority.yaml +0 -207
  125. package/core/.rule/general/synapse-cognitive-fusion.yaml +0 -301
  126. package/core/.specialist/Ansuz/concepts/cognitive_systems_hippocampus.yaml +0 -14
  127. package/core/.specialist/Ansuz/concepts/write_verification.yaml +0 -26
  128. package/core/.specialist/Ansuz/modules/recall.yaml +0 -45
  129. package/core/.specialist/tools/MEMORY_RECALL_TRIGGERS.md +0 -156
  130. package/core/.specialist/tools/context-injection.js +0 -395
  131. package/core/dev/scripts/probe/hippocampus-concurrency.js +0 -247
@@ -170,101 +170,6 @@ function releaseClaimMarker(taskId, projectRoot) {
170
170
  try { require('fs').unlinkSync(markerPath); } catch { /* already gone */ }
171
171
  }
172
172
 
173
- // ─────────────────────────────────────────
174
- // TASK_1861: Dispatch-path-agnostic health snapshot capture
175
- // _before at activation (move/claim/update→active), _after + observe-only verdict
176
- // at completion (complete AND bare `move X done`). Fires a detached child so the
177
- // short-lived CLI never blocks, fails, or slows on Dashboard state. Every capture
178
- // attempt is audited in .kenaz/health_snapshots/snapshot_events.jsonl (never silent).
179
- // Reuses .formation/tools/post-complete/health.js (measureHealthScoreSync etc.) —
180
- // no third implementation. Does NOT touch guardian_counter.json (TASK_1720 periodic
181
- // counter semantics unchanged; verdict stays observe-only, no git action).
182
- // ─────────────────────────────────────────
183
-
184
- function _appendSnapshotEvent(projectRoot, taskId, phase, action, reason) {
185
- try {
186
- const dir = path.join(projectRoot, '.kenaz', 'health_snapshots');
187
- fsSync.mkdirSync(dir, { recursive: true });
188
- const line = JSON.stringify({
189
- task_id: taskId,
190
- ts: new Date().toISOString(),
191
- snapshot_type: phase,
192
- action,
193
- ...(reason ? { reason } : {}),
194
- });
195
- fsSync.appendFileSync(path.join(dir, 'snapshot_events.jsonl'), line + '\n', 'utf8');
196
- } catch { /* audit is best-effort — must never block task ops */ }
197
- }
198
-
199
- function scheduleHealthSnapshotCapture(taskId, phase, projectRoot) {
200
- try {
201
- if (!taskId || !projectRoot) return;
202
- const healthJs = path.join(__dirname, '..', '..', '.formation', 'tools', 'post-complete', 'health.js');
203
- if (!fsSync.existsSync(healthJs)) {
204
- _appendSnapshotEvent(projectRoot, taskId, phase, 'skipped', 'health_module_missing');
205
- return;
206
- }
207
- const cfg = { taskId, phase, projectRoot, healthJs };
208
- // ASCII-only inline script (Windows argv codepage safety). Child owns the full
209
- // outcome audit: written | skipped(reason). measureHealthScoreSync already
210
- // handles Dashboard-down gracefully (returns null within its own timeout).
211
- const script = [
212
- `const CFG = ${JSON.stringify(cfg)};`,
213
- "const fs = require('fs');",
214
- "const path = require('path');",
215
- "function logEvent(action, reason) {",
216
- " try {",
217
- " const dir = path.join(CFG.projectRoot, '.kenaz', 'health_snapshots');",
218
- " fs.mkdirSync(dir, { recursive: true });",
219
- " const line = JSON.stringify({ task_id: CFG.taskId, ts: new Date().toISOString(), snapshot_type: CFG.phase, action, ...(reason ? { reason } : {}) });",
220
- " fs.appendFileSync(path.join(dir, 'snapshot_events.jsonl'), line + '\\n', 'utf8');",
221
- " } catch (_e) { /* best-effort */ }",
222
- "}",
223
- "try {",
224
- " const snapFile = path.join(CFG.projectRoot, '.kenaz', 'health_snapshots', CFG.taskId + '_' + CFG.phase + '.json');",
225
- " if (fs.existsSync(snapFile)) { logEvent('skipped', 'snapshot_already_exists'); process.exit(0); }",
226
- " const h = require(CFG.healthJs);",
227
- " const health = h.measureHealthScoreSync(CFG.projectRoot);",
228
- " if (!health || typeof health.score !== 'number') { logEvent('skipped', 'dashboard_unreachable'); process.exit(0); }",
229
- // TASK_1891: capture the written payload (delta_from_previous) for
230
- // observability threading into computeHealthVerdict below -- see
231
- // health.js's computeHealthVerdict doc comment for why that field
232
- // never gates the verdict itself.
233
- " const afterPayload = h.saveTaskHealthSnapshot(CFG.taskId, CFG.phase, health, CFG.projectRoot);",
234
- " logEvent('written');",
235
- " if (CFG.phase === 'after') {",
236
- " const pre = h.readPreTaskHealthSnapshot(CFG.taskId, CFG.projectRoot);",
237
- " if (pre && typeof pre.score === 'number') {",
238
- " const loopCfg = h.readLoopConfig(CFG.projectRoot);",
239
- " const threshold = typeof loopCfg.revert_threshold === 'number' ? loopCfg.revert_threshold : h.DEFAULT_REVERT_THRESHOLD;",
240
- // TASK_1890/1891: same computeHealthVerdict as post-complete.js's
241
- // auto/manual arms -- no third re-implementation of the delta/trust-gate
242
- // logic. No git access here (no isScopeUnchanged callback), so guard 3
243
- // (stale snapshot + zero scope diff) is a safe no-op for this call
244
- // site, same as it always was before TASK_1890. TASK_1891: pass the
245
- // full `pre` object (not pre.score) so ordering + symmetric trust can
246
- // be checked -- this is one of the two call sites that actually runs
247
- // in production (readLoopConfig's TASK_1885 note).
248
- " const vr = h.computeHealthVerdict(pre, health, threshold, { projectPath: CFG.projectRoot, trendDeltaFromPrevious: afterPayload ? afterPayload.delta_from_previous : null });",
249
- " const reason = vr.verdict === 'REVERT'",
250
- " ? vr.reason + ' - observe-only capture at task completion, no git action taken'",
251
- " : vr.reason;",
252
- " h.writeVerdictToTask(CFG.taskId, vr.verdict, vr.delta, reason, CFG.projectRoot);",
253
- " }",
254
- " }",
255
- "} catch (e) { logEvent('skipped', 'error: ' + String(e && e.message || e)); }",
256
- ].join('\n');
257
- const { spawn } = require('child_process');
258
- const child = spawn(process.execPath, ['-e', script], {
259
- detached: true,
260
- stdio: 'ignore',
261
- windowsHide: true,
262
- });
263
- child.unref();
264
- } catch (e) {
265
- _appendSnapshotEvent(projectRoot, taskId, phase, 'skipped', 'spawn_failed: ' + String(e && e.message || e));
266
- }
267
- }
268
173
  const yaml = (() => {
269
174
  try { return require('js-yaml'); } catch {
270
175
  // Fallback: resolve .vendor/js-yaml.cjs relative to this script's real location.
@@ -311,6 +216,9 @@ function getTaskSync() {
311
216
  return _taskSync;
312
217
  }
313
218
 
219
+ // TASK_2421: shared-room card replicas (relay) -- pure helper module, safe to require eagerly.
220
+ const taskReplica = require('./task-replica');
221
+
314
222
  // TASK_2142 R2: Task Pool status push (lazy loaded, graceful degradation --
315
223
  // a sub-project checkout without .formation/ must not break moveTask).
316
224
  // Overridable via setPoolStatusPush() so tests can inject a fake/spy
@@ -582,6 +490,11 @@ function emitEmptyListDiagnostics(tasksDir) {
582
490
  } catch { /* never break list */ }
583
491
  }
584
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
+
585
498
  class TaskAPI {
586
499
  /**
587
500
  * @param {Object} config
@@ -1663,6 +1576,14 @@ class TaskAPI {
1663
1576
  // { level_1: string[], level_2: [{endpoint, method, expect}], level_3: {scenario, generates} }
1664
1577
  if (taskData.verification) task.verification = taskData.verification;
1665
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
1666
1587
 
1667
1588
  // TASK_1110: parallel scope overlap warning (non-blocking).
1668
1589
  // When two tasks declare overlapping scope_paths / file references in
@@ -1699,16 +1620,146 @@ class TaskAPI {
1699
1620
  task._file_path = filePath;
1700
1621
  task._status_dir = 'backlog';
1701
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
+
1702
1641
  return task;
1703
1642
  }
1704
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
+
1705
1756
  /**
1706
1757
  * 更新任務
1707
1758
  */
1708
1759
  async updateTask(taskId, updates) {
1709
1760
  const task = await this.getTask(taskId);
1710
1761
  if (!task) {
1711
- throw new Error(`任務不存在: ${taskId}`);
1762
+ throw await this._missingTaskError(taskId);
1712
1763
  }
1713
1764
 
1714
1765
  // TASK_1771: coerce array-typed fields so a raw-string update can never
@@ -1762,6 +1813,8 @@ class TaskAPI {
1762
1813
  // 垃圾值只污染回傳值,不落 YAML(toYAML 過濾底線鍵 + 寫檔前已 delete)。
1763
1814
  updatedTask._status_dir = this._resolveStatusDirName(filePath);
1764
1815
 
1816
+ await this._syncReplica(updatedTask, undefined); // TASK_2421: content refresh (title/alias/...)
1817
+
1765
1818
  return updatedTask;
1766
1819
  }
1767
1820
 
@@ -1770,16 +1823,14 @@ class TaskAPI {
1770
1823
  */
1771
1824
  async moveTask(taskId, newStatus, taskData = null, options = {}) {
1772
1825
  await this._ensureStatusDirs();
1773
- // TASK_2264: skipHealthAfter = the caller (completeTask) knows post-complete will measure and
1774
- // save the 'after' snapshot itself, so scheduling a second detached measurement is pure waste.
1775
- const { skipValidation = false, forceClaim = false, skipHealthAfter = false } = options;
1826
+ const { skipValidation = false, forceClaim = false } = options;
1776
1827
 
1777
1828
  // 取得任務資料
1778
1829
  let task = taskData;
1779
1830
  if (!task) {
1780
1831
  task = await this.getTask(taskId);
1781
1832
  if (!task) {
1782
- throw new Error(`任務不存在: ${taskId}`);
1833
+ throw await this._missingTaskError(taskId);
1783
1834
  }
1784
1835
  }
1785
1836
 
@@ -1919,20 +1970,6 @@ class TaskAPI {
1919
1970
  releaseClaimMarker(taskId, _projectRootForClaim);
1920
1971
  }
1921
1972
 
1922
- // TASK_1861: dispatch-path-agnostic health capture. moveTask is the single choke
1923
- // point every activation/completion path funnels through (claim → moveTask,
1924
- // update --status → moveTask, complete → moveTask, bare move). Fire-and-forget
1925
- // detached child; idempotent only against snapshots that ALREADY exist when the child
1926
- // starts (it can race a concurrent writer). TASK_2264: completeTask therefore passes
1927
- // skipHealthAfter when its own in-process postComplete will measure 'after', so that
1928
- // path is measured exactly once; paths with no post-complete (bare move, update --status,
1929
- // complete --skip-post-complete) keep this capture as their only one.
1930
- if (targetDir === 'active') {
1931
- scheduleHealthSnapshotCapture(taskId, 'before', _projectRootForClaim);
1932
- } else if (targetDir === 'done' && !skipHealthAfter) {
1933
- scheduleHealthSnapshotCapture(taskId, 'after', _projectRootForClaim);
1934
- }
1935
-
1936
1973
  // Yggdrasil: sync status change to kenaz_tasks
1937
1974
  const sync = getTaskSync();
1938
1975
  if (sync) {
@@ -1955,6 +1992,9 @@ class TaskAPI {
1955
1992
  projectPath: this.projectPath,
1956
1993
  }).catch(() => {});
1957
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
+
1958
1998
  return updatedTask;
1959
1999
  }
1960
2000
 
@@ -1985,7 +2025,7 @@ class TaskAPI {
1985
2025
 
1986
2026
  const task = await this.getTask(taskId);
1987
2027
  if (!task) {
1988
- throw new Error('任務不存在: ' + taskId);
2028
+ throw await this._missingTaskError(taskId);
1989
2029
  }
1990
2030
 
1991
2031
  // M1: acquire claim marker (wx atomic — no TOCTOU)
@@ -2086,7 +2126,7 @@ class TaskAPI {
2086
2126
 
2087
2127
  const task = await this.getTask(taskId);
2088
2128
  if (!task) {
2089
- throw new Error(`任務不存在: ${taskId}`);
2129
+ throw await this._missingTaskError(taskId);
2090
2130
  }
2091
2131
 
2092
2132
  // TASK_2233: backlog → done is not an allowed transition; say what to do instead
@@ -2126,13 +2166,9 @@ class TaskAPI {
2126
2166
  updatedTask.actual_hours = options.actual_hours;
2127
2167
  }
2128
2168
 
2129
- // TASK_2264: decide up front whether post-complete (below) will measure 'after' in-process.
2130
- // TASK_2288 single-winner rule: whenever a post-complete run is expected for this completion,
2131
- // post-complete's own measurement is the ONE 'after' capture (it runs after merge and feeds the
2132
- // verdict), so moveTask must not also spawn one. Expected = in-process (below).
2133
- // moveTask's detached capture stays the sole one only when nobody measures: --skip-post-complete.
2134
2169
  const postCompleteRuns = !options.skipPostComplete;
2135
- const result = await this.moveTask(taskId, 'done', updatedTask, { skipHealthAfter: postCompleteRuns });
2170
+ let postCompleteSummary = null;
2171
+ const result = await this.moveTask(taskId, 'done', updatedTask);
2136
2172
 
2137
2173
  try {
2138
2174
  this._autoPopThoughtsForCompletedTask(taskId, now);
@@ -2156,7 +2192,7 @@ class TaskAPI {
2156
2192
  try {
2157
2193
  const { postComplete } = require('../../.formation/tools/post-complete.js');
2158
2194
  console.log(`\n🔗 自動執行 post-complete for ${taskId}...\n`);
2159
- await postComplete({
2195
+ const pc = await postComplete({
2160
2196
  taskId,
2161
2197
  keep: options.keep || 10,
2162
2198
  skipArchive: options.skipArchive || false,
@@ -2164,13 +2200,19 @@ class TaskAPI {
2164
2200
  dryRun: options.dryRun || false,
2165
2201
  message: options.message,
2166
2202
  projectPath: this.projectPath,
2203
+ noSource: options.noSource || false,
2167
2204
  });
2205
+ postCompleteSummary = (pc && pc.summary) || null;
2168
2206
  } catch (error) {
2169
2207
  console.error(`⚠️ post-complete 失敗: ${error.message}`);
2170
- 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 }] };
2171
2211
  }
2172
2212
  }
2173
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 });
2174
2216
  return result;
2175
2217
  }
2176
2218
 
@@ -2227,7 +2269,8 @@ class TaskAPI {
2227
2269
  const desc = (t.description || '').toLowerCase();
2228
2270
  const id = (t.id || '').toLowerCase();
2229
2271
  const tags = (t.tags || []).join(' ').toLowerCase();
2230
- 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));
2231
2274
  });
2232
2275
  }
2233
2276
 
@@ -2520,100 +2563,6 @@ class TaskAPI {
2520
2563
  }
2521
2564
  }
2522
2565
 
2523
- // ============================================================================
2524
- // TASK_1992/TASK_2104 — Alaya process-lessons echo (best-effort, create-only)
2525
- // ============================================================================
2526
- // Mirrors prompt.js's appendAlayaLessons: same query, same alaya_recall
2527
- // shape, and (TASK_2104) the same TASK_2101 cosine-floor + stack-match
2528
- // gate — loaded from the dependency-free .formation/tools/alaya-gate.js
2529
- // module (require() resolved relative to __dirname; GEN-008) instead of
2530
- // the hand-copied query builder that used to live here with no gate at all.
2531
- // Never affects create's exit code: any failure is swallowed silently.
2532
- // Opt-out: KENAZ_NO_ALAYA_ECHO=1.
2533
-
2534
- // Best-effort: null (not a throw) when unavailable — e.g. a sub-project
2535
- // with no .formation/ checkout — so the caller prints nothing rather than
2536
- // falling back to ungated results.
2537
- function loadAlayaGate() {
2538
- try {
2539
- return require('../../.formation/tools/alaya-gate');
2540
- } catch {
2541
- return null;
2542
- }
2543
- }
2544
-
2545
- // fetchImpl/gateImpl: injectable test seams; default to the real fetch and loadAlayaGate().
2546
- async function echoAlayaLessons(taskData, fetchImpl, gateImpl) {
2547
- if (process.env.KENAZ_NO_ALAYA_ECHO === '1') return;
2548
-
2549
- const doFetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : null);
2550
- if (!doFetch) return;
2551
-
2552
- const gate = gateImpl !== undefined ? gateImpl : loadAlayaGate();
2553
- if (!gate) return; // shared gate unavailable — never print ungated results
2554
-
2555
- const query = gate.buildTaskContextQuery(taskData);
2556
- if (!query) return;
2557
-
2558
- const taskStack = gate.deriveTaskStack((taskData && taskData.scope_paths) || []);
2559
-
2560
- const controller = new AbortController();
2561
- const timeoutId = setTimeout(() => controller.abort(), 3000);
2562
- try {
2563
- const payload = JSON.stringify({
2564
- jsonrpc: '2.0',
2565
- id: 1,
2566
- method: 'tools/call',
2567
- params: {
2568
- name: 'alaya_recall',
2569
- arguments: {
2570
- query, mode: 'hybrid', seed_types: ['failure_seed', 'lesson_seed'],
2571
- limit: gate.ALAYA_CANDIDATE_POOL, min_cosine: gate.ALAYA_MIN_COSINE_DEFAULT,
2572
- },
2573
- },
2574
- });
2575
- const res = await doFetch('http://127.0.0.1:18080/api/mcp', {
2576
- method: 'POST',
2577
- headers: { 'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream' },
2578
- body: payload,
2579
- signal: controller.signal,
2580
- });
2581
- if (!res || !res.ok) return;
2582
- const result = await res.json();
2583
- if (!result || result.error) return;
2584
-
2585
- const content = result.result && result.result.content;
2586
- if (!content || !Array.isArray(content)) return;
2587
- const textPart = content.find(c => c.type === 'text');
2588
- if (!textPart || !textPart.text) return;
2589
-
2590
- let recallData;
2591
- try { recallData = JSON.parse(textPart.text); } catch { return; }
2592
- if (!recallData) return;
2593
-
2594
- let bijas = recallData.bijas || [];
2595
- if (bijas.length === 0) return;
2596
-
2597
- bijas = gate.dedupBijas(bijas);
2598
- // TASK_2104: same relevance gate as prompt.js's appendAlayaLessons —
2599
- // the server already applied min_cosine, but re-checking (+ the
2600
- // stack-match half, which the server does not apply) keeps this
2601
- // correct against a stub/older server and drops stack mismatches.
2602
- bijas = bijas.filter(b => gate.passesAlayaRelevanceGate(b, taskStack, gate.ALAYA_MIN_COSINE_DEFAULT)).slice(0, 3);
2603
- if (bijas.length === 0) return;
2604
-
2605
- console.log(' Alaya lessons for this card:');
2606
- for (const b of bijas) {
2607
- const preview = (b.content || '').replace(/\s+/g, ' ').trim().slice(0, 160);
2608
- console.log(` - [${b.seed_type || 'seed'}/${b.origin || 'unknown'}] ${preview}`);
2609
- }
2610
- } catch {
2611
- // Best-effort: MCP down, timeout, network error, malformed response — silent.
2612
- } finally {
2613
- clearTimeout(timeoutId);
2614
- }
2615
- }
2616
-
2617
2566
  // TASK_2257: estimated_hours is a finite number >= 0 or null. '' (flag present, empty value)
2618
2567
  // CLEARS the field (null) -- same "clearable" contract as --description/--notes. Anything
2619
2568
  // non-numeric or negative exits 1 instead of persisting NaN / a nonsense value.
@@ -2698,6 +2647,7 @@ Commands:
2698
2647
  node task-api.js update TASK_ID [--title T] [--description D] [--priority P] [--role R] [--technical-notes N]...
2699
2648
  陣列欄位(tags/dependencies/scope-paths/requirements/acceptance-criteria/technical-notes 等)
2700
2649
  預設為整組取代(會回顯被移除的既有項目);加 --append 改為追加到既有項目之後(與既有重複的項目會略過並回顯)
2650
+ node task-api.js pull TASK_ID 匯入共享 room 的唯讀 replica 為本機卡片(標記 origin_node;TASK_2421)
2701
2651
  node task-api.js claim TASK_ID
2702
2652
  node task-api.js complete TASK_ID [--actual-hours HOURS]
2703
2653
  node task-api.js reconcile [--remote origin] [--base main] [--apply] [--worktrees] 關閉分支已併入 base 的 active 任務(預設 dry-run)
@@ -2751,6 +2701,7 @@ Create 選項:
2751
2701
  --args-file PATH UTF-8 JSON 物件;支援 camelCase / snake_case,CLI 旗標優先
2752
2702
  requirements / acceptance_criteria / technical_notes 使用 JSON 陣列
2753
2703
  一般品質門檻:requirements >= 3,acceptance_criteria >= 3,technical_notes >= 2
2704
+ --alias PLAN_ID 計畫文件編號(如 791-17),get/update/move/complete/search 皆可用(TASK_2421)
2754
2705
  --estimated-hours N 預估工時(小時)
2755
2706
  --branch NAME Git 分支
2756
2707
  --notes TEXT 備註
@@ -2787,6 +2738,11 @@ Create 選項:
2787
2738
 
2788
2739
  const command = args[0];
2789
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
+
2790
2746
  if (command === 'reconcile') {
2791
2747
  // TASK_2404: close active cards whose branches already landed on <remote>/<base>
2792
2748
  const argOf = (n, d) => { const i = args.indexOf(n); return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : d; };
@@ -3056,14 +3012,32 @@ Create 選項:
3056
3012
  console.error('錯誤: 缺少任務 ID');
3057
3013
  process.exit(1);
3058
3014
  }
3059
- 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;
3060
3017
  if (task) {
3061
3018
  console.log(JSON.stringify(task, null, 2));
3062
3019
  } else {
3063
- console.error(`任務不存在: ${taskId}`);
3064
- 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
+ }
3065
3032
  }
3066
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
+
3067
3041
  } else if (command === 'create') {
3068
3042
  // TASK_1599: Windows CreateProcess caps command line at 32767 UTF-16 chars.
3069
3043
  // Detailed task cards (long description + many requirements) easily exceed this
@@ -3161,6 +3135,7 @@ Create 選項:
3161
3135
  // is the documented flag name; it maps onto the pre-existing `source`
3162
3136
  // field that createTask() already promotes onto the task when truthy.
3163
3137
  source: getArg('--source') || getArg('--origin') || fromFile('source') || fromFile('origin') || null,
3138
+ alias: getArg('--alias') || fromFile('alias') || undefined, // TASK_2421
3164
3139
  };
3165
3140
 
3166
3141
  const reqs = getMulti('--requirements');
@@ -3197,8 +3172,9 @@ Create 選項:
3197
3172
  if (task.scope_paths && task.scope_paths.length > 0) console.log(`範圍: ${task.scope_paths.join(', ')}`);
3198
3173
  if (task.estimated_hours != null) console.log(`預估工時: ${task.estimated_hours}h`);
3199
3174
  if (task.branch) console.log(`分支: ${task.branch}`);
3200
-
3201
- await echoAlayaLessons(taskData);
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}`);
3202
3178
 
3203
3179
  } else if (command === 'claim') {
3204
3180
  const taskId = args[1];
@@ -3233,9 +3209,29 @@ Create 選項:
3233
3209
  if (args.includes('--skip-post-complete')) {
3234
3210
  options.skipPostComplete = true;
3235
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
+ }
3236
3216
  const task = await api.completeTask(taskId, options);
3237
- console.log(`✅ 任務已完成: ${task.id}`);
3238
- 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
+ }
3239
3235
 
3240
3236
  } else if (command === 'move') {
3241
3237
  const taskId = args[1];
@@ -3586,6 +3582,7 @@ Create 選項:
3586
3582
  const reqs = getMulti('--requirements'); if (reqs) updates.requirements = reqs;
3587
3583
  const ac = getMulti('--acceptance-criteria'); if (ac) updates.acceptance_criteria = ac;
3588
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
3589
3586
  const mergeLandedArg = getArg('--merge-landed');
3590
3587
  if (mergeLandedArg !== null) updates.merge_landed = (mergeLandedArg === 'true');
3591
3588
 
@@ -3737,10 +3734,6 @@ module.exports = {
3737
3734
  parseYAML,
3738
3735
  toYAML,
3739
3736
  resolveTasksDir,
3740
- // TASK_1992/TASK_2104: exported for direct unit-testing with a mocked
3741
- // fetch (and an injectable alaya-gate module) — no live MCP dependency.
3742
- loadAlayaGate,
3743
- echoAlayaLessons,
3744
3737
  // TASK_2142 R2: test seam for injecting a fake pool-status-push module.
3745
3738
  setPoolStatusPush,
3746
3739
  };