kenaz 0.3.6 → 0.3.7

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.
@@ -0,0 +1,193 @@
1
+ 'use strict';
2
+ /**
3
+ * TASK_2404: close cards whose task branches already landed on <remote>/<base>
4
+ * (cross-node pool merge, external rebase+merge) without running post-complete.
5
+ *
6
+ * landed(card) = the card has >=1 task branch (local or remote task/<id>*, or card.branch)
7
+ * AND every one of them is an ancestor of <remote>/<base> AND each carries >=1 commit of its own
8
+ * (a freshly forked, still-empty branch is trivially an ancestor of base; it has landed nothing).
9
+ *
10
+ * Only local refs are read (the <remote>/<base> remote-tracking ref); nothing here fetches.
11
+ */
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { execFileSync } = require('child_process');
15
+
16
+ const TELEMETRY = ['.kenaz/sessions', '.kenaz/specialist', '.formation/.dispatch-branch', '.formation/analytics', '.kenaz/channel_heartbeats.jsonl'];
17
+ const LIVE_PIDLESS_MS = 3600000; // pid-less dispatch entries count as live for 60 min (DISPATCH_LOCK_STALE_MS)
18
+
19
+ function git(cwd, args) {
20
+ return execFileSync('git', args, { cwd, encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 }).trim();
21
+ }
22
+ function gitOk(cwd, args) { try { git(cwd, args); return true; } catch { return false; } }
23
+ const norm = (p) => path.resolve(p).replace(/\\/g, '/').toLowerCase();
24
+ const isTelemetry = (rel) => TELEMETRY.some((t) => rel === t || rel.startsWith(t + '/'));
25
+
26
+ /** Remote-tracking base ref when present, else the local base branch (null when neither exists). */
27
+ function resolveBaseRef(repo, remote, base) {
28
+ for (const ref of [`refs/remotes/${remote}/${base}`, `refs/heads/${base}`]) {
29
+ if (gitOk(repo, ['rev-parse', '--verify', '--quiet', ref])) return ref;
30
+ }
31
+ return null;
32
+ }
33
+
34
+ /** Branch refs of a card: local + remote task/<id>* plus card.branch. [{ref, name, sha}] */
35
+ function findCardBranches(repo, card, remote) {
36
+ const idLower = String(card.id).toLowerCase();
37
+ const out = git(repo, ['for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads/task', `refs/remotes/${remote}/task`]);
38
+ const prefixes = [`refs/heads/task/${idLower}`, `refs/remotes/${remote}/task/${idLower}`];
39
+ const found = [];
40
+ for (const line of out.split(/\r?\n/).filter(Boolean)) {
41
+ const [ref, sha] = line.split(' ');
42
+ // `task/task_12` must not match `task/task_123`: allow only a non-digit after the id.
43
+ if (prefixes.some((p) => ref === p || (ref.startsWith(p) && !/^\d/.test(ref.slice(p.length))))) found.push({ ref, sha });
44
+ }
45
+ if (card.branch) {
46
+ for (const ref of [`refs/heads/${card.branch}`, `refs/remotes/${remote}/${card.branch}`]) {
47
+ if (found.some((f) => f.ref === ref)) continue;
48
+ try { found.push({ ref, sha: git(repo, ['rev-parse', '--verify', '--quiet', ref]) }); } catch { /* absent */ }
49
+ }
50
+ }
51
+ return found.map((f) => ({ ...f, name: f.ref.replace(/^refs\/(heads|remotes\/[^/]+)\//, '') }));
52
+ }
53
+
54
+ /** True unless the LOCAL branch's reflog proves its tip is still its creation commit (no commits of its own). */
55
+ function hasOwnCommits(repo, b) {
56
+ if (!b.ref.startsWith('refs/heads/')) return true; // remote-tracking refs carry no reflog
57
+ try {
58
+ const lines = git(repo, ['reflog', 'show', '--format=%H', b.ref]).split(/\r?\n/).filter(Boolean);
59
+ return !(lines.length > 0 && lines[lines.length - 1] === b.sha);
60
+ } catch { return true; }
61
+ }
62
+
63
+ /** First commit on base that contains `sha` (one ancestry-path walk); `sha` itself when nothing lies between. */
64
+ function landingCommit(repo, baseRef, sha) {
65
+ try {
66
+ const first = git(repo, ['rev-list', '--reverse', '--ancestry-path', `${sha}..${baseRef}`]).split(/\r?\n/)[0];
67
+ return first || sha;
68
+ } catch { return null; }
69
+ }
70
+
71
+ /** @returns {{landed:boolean, branches:object[], landing:string|null, baseRef:string|null, reason?:string}} */
72
+ function assessCard(repo, card, { remote = 'origin', base = 'main' } = {}) {
73
+ const baseRef = resolveBaseRef(repo, remote, base);
74
+ if (!baseRef) return { landed: false, branches: [], landing: null, baseRef, reason: `no ${remote}/${base} or ${base} ref` };
75
+ const branches = findCardBranches(repo, card, remote);
76
+ if (!branches.length) return { landed: false, branches, landing: null, baseRef, reason: 'no branch' };
77
+ let reason = null;
78
+ for (const br of branches) {
79
+ if (!gitOk(repo, ['merge-base', '--is-ancestor', br.sha, baseRef])) reason = `${br.name} is not an ancestor of base`;
80
+ else if (!hasOwnCommits(repo, br)) reason = `${br.name} has no commits beyond its fork point (fresh branch, nothing landed)`;
81
+ if (reason) break;
82
+ }
83
+ const landed = !reason;
84
+ return { landed, branches, landing: landed ? landingCommit(repo, baseRef, branches[0].sha) : null, baseRef, reason };
85
+ }
86
+
87
+ /** Why a landed card's worktree must be kept; null = safe to remove. */
88
+ function worktreeKeepReason(repo, wt, card, baseRef, activeDispatches, now) {
89
+ if (!fs.existsSync(wt.path)) return 'path missing';
90
+ const p = norm(wt.path);
91
+ const cwd = norm(process.cwd());
92
+ if (cwd === p || cwd.startsWith(p + '/')) return 'current working directory is inside it';
93
+ if (wt.locked) return 'locked';
94
+ if (!wt.branch) return 'detached HEAD';
95
+ const live = (activeDispatches || []).some((d) => {
96
+ if (!((d.worktree_path && norm(d.worktree_path) === p) || d.taskId === card.id)) return false;
97
+ if (d.pid) { try { process.kill(d.pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } }
98
+ return now - Date.parse(d.startedAt || 0) < LIVE_PIDLESS_MS;
99
+ });
100
+ if (live) return 'live dispatch/agent registered for it';
101
+ if (!gitOk(wt.path, ['merge-base', '--is-ancestor', 'HEAD', baseRef])) return 'HEAD is not an ancestor of base';
102
+ if (!hasOwnCommits(repo, { ref: `refs/heads/${wt.branch}`, sha: git(wt.path, ['rev-parse', 'HEAD']) })) return 'no commits beyond its fork point (fresh worktree)';
103
+ const dirty = git(wt.path, ['status', '--porcelain', '--untracked-files=no']).split(/\r?\n/).filter(Boolean)
104
+ .map((l) => l.slice(3).replace(/^"|"$/g, '')).filter((f) => !isTelemetry(f));
105
+ if (dirty.length) return `tracked tree dirty: ${dirty.slice(0, 3).join(', ')}`;
106
+ const stack = [wt.path];
107
+ while (stack.length) {
108
+ const dir = stack.pop();
109
+ let names; try { names = fs.readdirSync(dir); } catch { continue; }
110
+ for (const n of names) {
111
+ if (n === '.git') continue;
112
+ const f = path.join(dir, n);
113
+ let st; try { st = fs.lstatSync(f); } catch { continue; }
114
+ // a node_modules link is dispatch-managed and unlinked before removal; any other link blocks
115
+ if (st.isSymbolicLink()) { if (n !== 'node_modules') return `contains symlink/junction: ${path.relative(wt.path, f)}`; }
116
+ else if (st.isDirectory()) stack.push(f);
117
+ }
118
+ }
119
+ return null;
120
+ }
121
+
122
+ function removeWorktree(repo, wt) {
123
+ require('../../.formation/tools/utils').unlinkNodeModulesBestEffort(wt.path); // `worktree remove` must not traverse the junction
124
+ git(repo, ['worktree', 'remove', '--force', wt.path]);
125
+ try { git(repo, ['worktree', 'prune']); } catch { /* non-fatal */ }
126
+ }
127
+
128
+ const isKeepOpen = (c) => c.keep_open === true || c.keep_open === 'true';
129
+
130
+ /**
131
+ * @param {TaskAPI} api
132
+ * @param {{remote?:string, base?:string, apply?:boolean, worktrees?:boolean, activeDispatches?:object[], now?:number, log?:Function}} opts
133
+ */
134
+ async function reconcile(api, opts = {}) {
135
+ const { remote = 'origin', base = 'main', apply = false, worktrees = false } = opts;
136
+ const log = opts.log || console.log;
137
+ const repo = api._resolveProjectRoot();
138
+ const now = opts.now || Date.now();
139
+ const active = opts.activeDispatches || (() => { try { return require('../../.formation/tools/dispatch-registry').readActiveDispatches(); } catch { return []; } })();
140
+ const wtList = worktrees ? require('../../.formation/tools/worktree-reclaim').listRegisteredWorktrees(repo).worktrees : [];
141
+ const rows = [];
142
+ for (const card of await api.queryTasks({ status: 'active' })) {
143
+ const a = assessCard(repo, card, { remote, base });
144
+ const keepOpen = isKeepOpen(card);
145
+ const mine = wtList.filter((w) => w.branch && a.branches.some((b) => b.name === w.branch));
146
+ const row = { card: card.id, branches: a.branches.map((b) => b.name), landed: a.landed, landing: a.landing, keepOpen, worktrees: mine.map((w) => w.path), closed: false, notes: [] };
147
+ if (a.landed && !keepOpen && apply) {
148
+ await api.completeTask(card.id, { skipPostComplete: true, external: { landing_sha: a.landing, landed_into: a.baseRef.replace(/^refs\/(heads|remotes)\//, '') } });
149
+ row.closed = true;
150
+ }
151
+ if (worktrees) {
152
+ for (const w of mine) {
153
+ const why = !a.landed ? `card not landed: ${a.reason}` : keepOpen ? 'card is keep_open' : worktreeKeepReason(repo, w, card, a.baseRef, active, now);
154
+ if (why) row.notes.push(`KEEP ${w.path}: ${why}`);
155
+ else if (!apply) row.notes.push(`would remove ${w.path}`);
156
+ else { try { removeWorktree(repo, w); row.notes.push(`removed ${w.path}`); } catch (e) { row.notes.push(`KEEP ${w.path}: remove failed: ${String(e.message).split('\n')[0]}`); } }
157
+ }
158
+ }
159
+ rows.push(row);
160
+ }
161
+ log(`reconcile ${apply ? '(APPLY)' : '(dry-run)'} against ${remote}/${base}: ${rows.length} active card(s)`);
162
+ for (const r of rows) {
163
+ const state = r.landed ? (r.keepOpen ? 'landed (keep_open: kept open)' : (r.closed ? 'landed -> closed' : 'landed')) : 'not landed';
164
+ log(`${r.card}\t${state}\tbranches=${r.branches.join(',') || '-'}\tlanding=${r.landing ? r.landing.slice(0, 10) : '-'}\tworktree=${r.worktrees.join(',') || '-'}`);
165
+ for (const n of r.notes) log(` ${n}`);
166
+ }
167
+ return rows;
168
+ }
169
+
170
+ /** The landed assessment, or null when not landed / not decidable (non-git dir, no base ref). */
171
+ function landedAssessment(repo, card, opts) {
172
+ try { const a = assessCard(repo, card, opts); return a.landed ? a : null; } catch { return null; }
173
+ }
174
+
175
+ /** Local-only count (no fetch; remote-tracking refs) of active cards already landed and not keep_open. */
176
+ function countLanded(repo, cards, opts) {
177
+ return cards.filter((c) => !isKeepOpen(c) && landedAssessment(repo, c, opts)).length;
178
+ }
179
+
180
+ /**
181
+ * Landed elsewhere = landed AND no registered worktree still has one of its branches checked out.
182
+ * A worktree on the branch means a local land just merged it (verify-and-land merges, then runs
183
+ * complete), so post-complete still has a delivery to read and complete must proceed.
184
+ */
185
+ function externalLanding(repo, card, opts) {
186
+ const a = landedAssessment(repo, card, opts);
187
+ if (!a) return null;
188
+ const names = new Set(a.branches.map((b) => b.name));
189
+ const wts = require('../../.formation/tools/worktree-reclaim').listRegisteredWorktrees(repo).worktrees;
190
+ return wts.some((w) => w.branch && names.has(String(w.branch).replace(/^refs\/heads\//, ''))) ? null : a;
191
+ }
192
+
193
+ module.exports = { reconcile, assessCard, landedAssessment, externalLanding, countLanded };
@@ -0,0 +1,135 @@
1
+ // TASK_2404 -- `task-api reconcile` + the plain-complete guard for externally landed cards.
2
+ // Temp git repos under os.tmpdir() only (origin is a local bare repo; nothing fetches, no network).
3
+ // Usage: node .collaboration/core/task-2404-reconcile.test.js
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+ const cp = require('child_process');
8
+
9
+ // No network / no side processes: stub task-sync, the pool push (recorded) and the detached health capture.
10
+ const syncPath = require.resolve('../../.formation/tools/task-sync');
11
+ require.cache[syncPath] = { id: syncPath, filename: syncPath, loaded: true, exports: { syncTaskStatus: async () => ({}) } };
12
+ const realSpawn = cp.spawn;
13
+ cp.spawn = function (cmd, argv, opts) {
14
+ if (Array.isArray(argv) && argv.some(a => typeof a === 'string' && a.includes('const CFG'))) return { unref() {}, on() {} };
15
+ return realSpawn.apply(this, arguments);
16
+ };
17
+ const { TaskAPI, toYAML, setPoolStatusPush } = require('./task-api.js');
18
+ const pushes = [];
19
+ setPoolStatusPush({ pushPoolStatus: async (a) => { pushes.push(a.task && a.task.id); return { pushed: true }; } });
20
+
21
+ const SB = fs.mkdtempSync(path.join(os.tmpdir(), 't2404_'));
22
+ const ORIGIN = path.join(SB, 'origin.git');
23
+ const REPO = path.join(SB, 'repo');
24
+ const WT = path.join(SB, 'wts');
25
+ const git = (cwd, ...a) => cp.execFileSync('git', a, { cwd, encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }).trim();
26
+ let pass = 0, fail = 0;
27
+ const check = (n, c, d) => { console.log((c ? 'PASS' : 'FAIL') + ' | ' + n + (c ? '' : ' | ' + d)); c ? pass++ : fail++; };
28
+ const capture = async (fn) => { const lines = []; const rows = await fn((l) => lines.push(l)); return { rows, out: lines.join('\n') }; };
29
+
30
+ function setup() {
31
+ fs.mkdirSync(ORIGIN, { recursive: true });
32
+ git(ORIGIN, 'init', '-q', '--bare', '-b', 'main');
33
+ fs.mkdirSync(REPO, { recursive: true });
34
+ git(REPO, 'init', '-q', '-b', 'main');
35
+ git(REPO, 'config', 'user.email', 't@example.com'); git(REPO, 'config', 'user.name', 't');
36
+ git(REPO, 'remote', 'add', 'origin', ORIGIN);
37
+ fs.writeFileSync(path.join(REPO, 'README.md'), 'base\n');
38
+ git(REPO, 'add', '-A'); git(REPO, 'commit', '-q', '-m', 'base');
39
+ git(REPO, 'push', '-q', 'origin', 'main');
40
+ for (const d of ['backlog', 'active', 'done', 'blocked', 'cancel', 'archived']) fs.mkdirSync(path.join(REPO, '.kenaz', 'tasks', d), { recursive: true });
41
+ }
42
+ // branch task/task_<n> with one commit, optionally in a worktree
43
+ function mkBranch(n, { worktree = false } = {}) {
44
+ const b = `task/task_${n}`;
45
+ if (worktree) { fs.mkdirSync(WT, { recursive: true }); git(REPO, 'worktree', 'add', '-q', '-b', b, path.join(WT, `task_${n}`), 'main'); }
46
+ else git(REPO, 'branch', b, 'main');
47
+ const dir = worktree ? path.join(WT, `task_${n}`) : null;
48
+ const work = dir || REPO;
49
+ if (!dir) git(REPO, 'checkout', '-q', b);
50
+ fs.writeFileSync(path.join(work, `f${n}.txt`), `work ${n}\n`);
51
+ git(work, 'add', '-A'); git(work, 'commit', '-q', '-m', `[TASK_${n}] work`);
52
+ if (!dir) git(REPO, 'checkout', '-q', 'main');
53
+ return { branch: b, dir };
54
+ }
55
+ const mergeToOriginMain = (b) => { git(REPO, 'merge', '-q', '--no-ff', '-m', `merge ${b}`, b); git(REPO, 'push', '-q', 'origin', 'main'); };
56
+ const card = (n, extra = {}) => fs.writeFileSync(path.join(REPO, '.kenaz', 'tasks', 'active', `TASK_${n}_t.yaml`),
57
+ toYAML({ id: `TASK_${n}`, title: `t${n}`, priority: 'P2', status: 'in_progress', ...extra }));
58
+ const inDir = (n, d) => fs.readdirSync(path.join(REPO, '.kenaz', 'tasks', d)).some(f => f.startsWith(`TASK_${n}_`));
59
+
60
+ (async () => {
61
+ try {
62
+ setup();
63
+ // A merged, B unmerged, C merged + keep_open; D clean landed worktree; E dirty landed worktree;
64
+ // F landed but a dispatch is live; G fresh worktree (no commits); H unmerged worktree
65
+ mkBranch(9001); mkBranch(9002); mkBranch(9003);
66
+ const D = mkBranch(9004, { worktree: true }), E = mkBranch(9005, { worktree: true }), F = mkBranch(9006, { worktree: true });
67
+ mkBranch(9008, { worktree: true });
68
+ fs.mkdirSync(WT, { recursive: true });
69
+ git(REPO, 'worktree', 'add', '-q', '-b', 'task/task_9007', path.join(WT, 'task_9007'), 'main'); // G: no commits
70
+ for (const n of [9001, 9003, 9004, 9005, 9006]) mergeToOriginMain(`task/task_${n}`);
71
+ fs.writeFileSync(path.join(E.dir, 'f9005.txt'), 'dirty edit\n'); // tracked + modified
72
+ for (const n of [9001, 9002]) card(n);
73
+ card(9003, { keep_open: true });
74
+
75
+ const api = new TaskAPI({ tasksDir: path.join(REPO, '.kenaz', 'tasks'), projectPath: REPO });
76
+
77
+ // dry-run
78
+ let r = await capture((log) => api.constructor && require('./reconcile').reconcile(api, { log }));
79
+ const row = (id) => r.rows.find(x => x.card === `TASK_${id}`);
80
+ check('dry-run: A landed', row(9001).landed === true && !!row(9001).landing, JSON.stringify(row(9001)));
81
+ check('dry-run: B not landed', row(9002).landed === false);
82
+ check('dry-run: C landed but kept open', row(9003).landed === true && row(9003).keepOpen === true && /keep_open/.test(r.out));
83
+ check('dry-run closes nothing', inDir(9001, 'active') && inDir(9003, 'active'));
84
+
85
+ // complete guard (new behavior): A refused, nothing written
86
+ let msg = null;
87
+ try { await api.completeTask('TASK_9001', {}); } catch (e) { msg = e.message; }
88
+ check('complete on landed card A is refused and points at reconcile', !!msg && /reconcile/.test(msg), msg);
89
+ check('refused complete left A active, wrote no outcome', inDir(9001, 'active') && !inDir(9001, 'done'));
90
+ // verify-and-land merges first, then runs complete while the task worktree still exists: not external
91
+ const { externalLanding, landedAssessment } = require('./reconcile');
92
+ const D4 = { id: 'TASK_9004' };
93
+ check('landed card with its worktree still present is not external (local land proceeds)',
94
+ !!landedAssessment(REPO, D4, {}) && externalLanding(REPO, D4, {}) === null);
95
+ check('landed card with no worktree is external', !!externalLanding(REPO, { id: 'TASK_9001' }, {}));
96
+
97
+ // --apply
98
+ pushes.length = 0;
99
+ r = await capture((log) => require('./reconcile').reconcile(api, { apply: true, log }));
100
+ check('apply closed A', inDir(9001, 'done') && !inDir(9001, 'active'));
101
+ check('apply left B active', inDir(9002, 'active'));
102
+ check('apply left keep_open C active', inDir(9003, 'active') && !inDir(9003, 'done'));
103
+ const doneFile = fs.readdirSync(path.join(REPO, '.kenaz', 'tasks', 'done')).find(f => f.startsWith('TASK_9001_'));
104
+ const doneTxt = fs.readFileSync(path.join(REPO, '.kenaz', 'tasks', 'done', doneFile), 'utf8');
105
+ const mainSha = git(REPO, 'rev-parse', 'origin/main');
106
+ const sha = (/^landing_sha:\s*(\w+)/m.exec(doneTxt) || [])[1];
107
+ check('A has integration: external', /^integration:\s*external\s*$/m.test(doneTxt), doneTxt);
108
+ check('A records the landing SHA (a commit on origin/main containing the branch)', !!sha && git(REPO, 'merge-base', '--is-ancestor', git(REPO, 'rev-parse', 'task/task_9001'), sha) === '' && git(REPO, 'merge-base', '--is-ancestor', sha, mainSha) === '', sha);
109
+ check('pool status pushed for the closed card', pushes.includes('TASK_9001'), JSON.stringify(pushes));
110
+
111
+ // worktree guards (dry-run first: nothing removed); these cards join after the first apply
112
+ for (const n of [9004, 9005, 9006, 9007, 9008]) card(n);
113
+ const live = [{ sessionId: 's', taskId: 'TASK_9006', worktree_path: F.dir, pid: process.pid, startedAt: new Date().toISOString() }];
114
+ r = await capture((log) => require('./reconcile').reconcile(api, { worktrees: true, activeDispatches: live, log }));
115
+ check('worktrees dry-run removes nothing', fs.existsSync(D.dir) && /would remove/.test(r.out));
116
+ r = await capture((log) => require('./reconcile').reconcile(api, { apply: true, worktrees: true, activeDispatches: live, log }));
117
+ check('landed clean worktree removed', !fs.existsSync(D.dir) && /removed .*task_9004/.test(r.out), r.out);
118
+ check('branch of removed worktree kept', git(REPO, 'branch', '--list', 'task/task_9004') !== '');
119
+ check('dirty worktree kept with reason', fs.existsSync(E.dir) && /KEEP .*task_9005: tracked tree dirty/.test(r.out), r.out);
120
+ check('live-dispatch worktree kept with reason', fs.existsSync(F.dir) && /KEEP .*task_9006: live dispatch/.test(r.out), r.out);
121
+ check('fresh worktree (no commits) kept with reason', fs.existsSync(path.join(WT, 'task_9007')) && /KEEP .*task_9007: .*no commits beyond its fork point/.test(r.out), r.out);
122
+ check('unlanded worktree kept with reason', fs.existsSync(path.join(WT, 'task_9008')) && /KEEP .*task_9008: card not landed/.test(r.out), r.out);
123
+
124
+ // boot-banner count is local-only and counts landed non-keep_open active cards
125
+ const n = require('./reconcile').countLanded(REPO, [{ id: 'TASK_9005' }, { id: 'TASK_9002' }, { id: 'TASK_9003', keep_open: true }], {});
126
+ check('countLanded: 1 (9005 landed; 9002 not; 9003 keep_open)', n === 1, String(n));
127
+ } catch (e) {
128
+ check('no exception', false, e && e.stack);
129
+ } finally {
130
+ cp.spawn = realSpawn;
131
+ try { fs.rmSync(SB, { recursive: true, force: true }); } catch { /* best effort */ }
132
+ }
133
+ console.log(`\n${pass} passed, ${fail} failed`);
134
+ process.exit(fail ? 1 : 0);
135
+ })();
@@ -2094,6 +2094,17 @@ class TaskAPI {
2094
2094
  throw new Error(`無法從 backlog 直接完成 ${taskId}: move it to active first (node task-api.js move ${taskId} active), then complete.`);
2095
2095
  }
2096
2096
 
2097
+ // TASK_2404: a card whose task branch is already an ancestor of <remote>/<base> landed elsewhere
2098
+ // (cross-node pool, external rebase+merge). Full complete would run post-complete on a tree with
2099
+ // nothing to deliver and write an empty-delivery outcome, so refuse and point at `reconcile`.
2100
+ // Paths that never run post-complete (--skip-post-complete, reconcile --apply) are unaffected.
2101
+ if (!options.skipPostComplete && !options.external) {
2102
+ const landed = require('./reconcile').externalLanding(this._resolveProjectRoot(), task, {});
2103
+ if (landed) {
2104
+ throw new Error(`${taskId}: branch ${landed.branches.map(b => b.name).join(', ')} already landed on ${landed.baseRef.replace(/^refs\/(heads|remotes)\//, '')} (${String(landed.landing).slice(0, 10)}); a full complete would record an empty delivery. Run: node .collaboration/core/task-api.js reconcile --apply (or complete ${taskId} --skip-post-complete).`);
2105
+ }
2106
+ }
2107
+
2097
2108
  const updatedTask = {
2098
2109
  ...task,
2099
2110
  executed_by: {
@@ -2103,6 +2114,13 @@ class TaskAPI {
2103
2114
  }
2104
2115
  };
2105
2116
 
2117
+ // TASK_2404: externally landed card (reconcile): record how and where it landed
2118
+ if (options.external) {
2119
+ updatedTask.integration = 'external';
2120
+ updatedTask.landing_sha = options.external.landing_sha || null;
2121
+ updatedTask.landed_into = options.external.landed_into || null;
2122
+ }
2123
+
2106
2124
  // 設置實際工時(如果提供)
2107
2125
  if (options.actual_hours !== undefined) {
2108
2126
  updatedTask.actual_hours = options.actual_hours;
@@ -2682,6 +2700,7 @@ Commands:
2682
2700
  預設為整組取代(會回顯被移除的既有項目);加 --append 改為追加到既有項目之後(與既有重複的項目會略過並回顯)
2683
2701
  node task-api.js claim TASK_ID
2684
2702
  node task-api.js complete TASK_ID [--actual-hours HOURS]
2703
+ node task-api.js reconcile [--remote origin] [--base main] [--apply] [--worktrees] 關閉分支已併入 base 的 active 任務(預設 dry-run)
2685
2704
  node task-api.js move TASK_ID STATUS [--force]
2686
2705
  node task-api.js delete TASK_ID (僅限 backlog 狀態)
2687
2706
  node task-api.js search KEYWORD
@@ -2768,7 +2787,14 @@ Create 選項:
2768
2787
 
2769
2788
  const command = args[0];
2770
2789
 
2771
- if (command === 'worktrees') {
2790
+ if (command === 'reconcile') {
2791
+ // TASK_2404: close active cards whose branches already landed on <remote>/<base>
2792
+ const argOf = (n, d) => { const i = args.indexOf(n); return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : d; };
2793
+ await require('./reconcile').reconcile(api, {
2794
+ remote: argOf('--remote', 'origin'), base: argOf('--base', 'main'),
2795
+ apply: args.includes('--apply'), worktrees: args.includes('--worktrees'),
2796
+ });
2797
+ } else if (command === 'worktrees') {
2772
2798
  // TASK_2297: list worktrees with reclaim eligibility; --reclaim removes eligible ones (manual path).
2773
2799
  const wr = require(path.join(__dirname, '..', '..', '.formation', 'tools', 'worktree-reclaim.js'));
2774
2800
  const wtRepo = path.resolve(cliProjectPath || process.cwd());
@@ -1022,6 +1022,8 @@ async function main() {
1022
1022
  const wishesLine = formatWishesLine(await fetchOpenWishes(mcpBase, WISH_TIMEOUT_MS, IS_PINNED ? BASE : null));
1023
1023
  if (wishesLine) lines.push(wishesLine);
1024
1024
  for (const l of openCardLines()) lines.push(l);
1025
+ const landedLine = landedActiveCardsLine();
1026
+ if (landedLine) lines.push(landedLine);
1025
1027
  const peerLines = await fetchPendingPeerLines(mcpBase, WISH_TIMEOUT_MS, IS_PINNED ? BASE : process.env.CLAUDE_PROJECT_DIR || process.cwd());
1026
1028
  for (const l of peerLines) lines.push(l);
1027
1029
  lines.push('(boot 完成:依 Open cards 提議程;小修正直接做,中型工作開卡派工)');
@@ -1039,6 +1041,24 @@ if (require.main === module) {
1039
1041
  });
1040
1042
  }
1041
1043
 
1044
+ // TASK_2404: active cards whose task branch is already an ancestor of origin/main (local remote-tracking
1045
+ // refs only -- no fetch at boot). Silent when none or when anything fails.
1046
+ function landedActiveCardsLine() {
1047
+ try {
1048
+ const dir = path.join(resolveTasksDir(), 'active');
1049
+ const pick = (txt, key) => {
1050
+ const m = txt.match(new RegExp('^' + key + ':[ \\t]*(.*)$', 'm'));
1051
+ return m ? m[1].trim().replace(/^['"]|['"]$/g, '') : '';
1052
+ };
1053
+ const cards = fs.readdirSync(dir).filter((f) => /\.ya?ml$/.test(f)).map((f) => {
1054
+ const txt = fs.readFileSync(path.join(dir, f), 'utf8');
1055
+ return { id: pick(txt, 'id'), branch: pick(txt, 'branch') || null, keep_open: pick(txt, 'keep_open') === 'true' };
1056
+ }).filter((c) => c.id);
1057
+ const n = require(path.join(__dirname, '..', '..', '.collaboration', 'core', 'reconcile.js')).countLanded(BASE, cards, {});
1058
+ return n > 0 ? '[reconcile] ' + n + ' active cards already landed (task-api reconcile)' : null;
1059
+ } catch { return null; }
1060
+ }
1061
+
1042
1062
  module.exports = {
1043
1063
  isOutsideAnyProject,
1044
1064
  harnessSessionIdFromEnv,
@@ -1 +1 @@
1
- 0.3.6
1
+ 0.3.7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kenaz",
3
- "version": "0.3.6",
3
+ "version": "0.3.7",
4
4
  "description": "Install Kenaz (plugin, hooks, kenaz-server) for Claude Code and Codex: npx kenaz install",
5
5
  "bin": {
6
6
  "kenaz": "bin/kenaz.js"
@@ -20,11 +20,11 @@
20
20
  "test": "node --test lib/cli.test.js"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@kenazai/server-darwin-arm64": "0.3.6",
24
- "@kenazai/server-darwin-x64": "0.3.6",
25
- "@kenazai/server-linux-arm64": "0.3.6",
26
- "@kenazai/server-linux-x64": "0.3.6",
27
- "@kenazai/server-win32-x64": "0.3.6"
23
+ "@kenazai/server-darwin-arm64": "0.3.7",
24
+ "@kenazai/server-darwin-x64": "0.3.7",
25
+ "@kenazai/server-linux-arm64": "0.3.7",
26
+ "@kenazai/server-linux-x64": "0.3.7",
27
+ "@kenazai/server-win32-x64": "0.3.7"
28
28
  },
29
29
  "license": "UNLICENSED",
30
30
  "repository": {