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 === '
|
|
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,
|
package/core/KENAZ_CORE_VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.3.
|
|
1
|
+
0.3.7
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kenaz",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
24
|
-
"@kenazai/server-darwin-x64": "0.3.
|
|
25
|
-
"@kenazai/server-linux-arm64": "0.3.
|
|
26
|
-
"@kenazai/server-linux-x64": "0.3.
|
|
27
|
-
"@kenazai/server-win32-x64": "0.3.
|
|
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": {
|