kenaz 0.3.11 → 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.
- package/core/.collaboration/core/task-2288-followups.test.js +2 -37
- package/core/.collaboration/core/task-2421-replica.test.js +229 -0
- package/core/.collaboration/core/task-api.js +221 -12
- package/core/.collaboration/core/task-replica.js +189 -0
- package/core/.formation/.merge-lock +1 -0
- package/core/.formation/bifrost/codex-doctor.js +12 -2
- package/core/.formation/bifrost/codex-doctor.test.js +15 -0
- package/core/.formation/bifrost/setup-codex.js +84 -16
- package/core/.formation/tools/post-complete/task2420.test.js +129 -0
- package/core/.formation/tools/post-complete.js +129 -8
- package/core/.kenaz/specialist/memory_guard_fired.json +1 -1
- package/core/.kenaz/specialist/memory_guard_trigger_cache.json +1 -1
- package/core/.specialist/tools/__tests__/rea-detect.test.js +10 -0
- package/core/.specialist/tools/rea-detect.js +6 -2
- package/core/.synapse/memory-shared.json.sync +1 -0
- package/core/KENAZ_CORE_VERSION +1 -1
- package/core/dev/plans/CLI_FIRST_INSTALL_FLOW.md +75 -109
- package/core/dev/specs/peer/CODEX_MANAGED_DAEMON.md +54 -0
- package/core/dev/specs/peer/setup-codex-hooks.test.js +38 -0
- package/core/dev/spikes/task2145-evidence-20261004/backend-log-excerpt.txt +56 -0
- package/core/dev/spikes/task2145-evidence-20261004/graph.shadow +0 -0
- package/core/dev/spikes/task2145-evidence-20261004/graph.wal +0 -0
- package/core/dev/spikes/task2145-evidence-20261004/graph_corrupt_suspect.marker +1 -0
- package/core/plugins/kenaz/scripts/kenaz.test.js +68 -0
- package/core/plugins/kenaz/scripts/ygg.js +43 -4
- package/lib/cli.js +17 -7
- package/lib/cli.test.js +10 -0
- package/lib/dashboard.test.js +3 -3
- package/package.json +6 -6
- package/vendor/install-kenaz-cli.js +167 -16
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2854
|
-
|
|
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
|
-
|
|
3026
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|