kenaz 0.3.5 → 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.
- package/core/.claude/commands/Ansuz.md +1 -0
- package/core/.collaboration/core/reconcile.js +193 -0
- package/core/.collaboration/core/task-2404-reconcile.test.js +135 -0
- package/core/.collaboration/core/task-api.js +27 -1
- package/core/.formation/native/claude/README.md +1 -1
- package/core/.formation/role-map.yaml +3 -3
- package/core/.formation/spec/task_operations.yaml +3 -3
- package/core/.formation/tools/__tests__/subagent-bifrost-guard.test.js +4 -4
- package/core/.formation/tools/dispatch.js +5 -5
- package/core/.formation/tools/prompt.js +4 -0
- package/core/.formation/unit/do.yaml +2 -4
- package/core/.rule/general/agent-workflow.yaml +4 -4
- package/core/.rule/general/file-size-threshold.yaml +16 -4
- package/core/.rule/general/rust-verify-target.yaml +0 -1
- package/core/.rule/general/system-invariants.yaml +8 -25
- package/core/.specialist/Ansuz/SYSTEM_DOC.md +17 -42
- package/core/.specialist/Ansuz/capability.yaml +7 -14
- package/core/.specialist/Ansuz/concepts/formation_quick_ref.yaml +4 -11
- package/core/.specialist/Ansuz/elixir.yaml +3 -4
- package/core/.specialist/Ansuz/identity_contract.yaml +1 -1
- package/core/.specialist/Ansuz/knowledge.yaml +7 -10
- package/core/.specialist/Ansuz/rule.yaml +2 -2
- package/core/.specialist/Ansuz/tools/verify-and-land.js +22 -0
- package/core/.specialist/Ansuz/tools/verify-and-land.test.js +59 -5
- package/core/.specialist/Ansuz/workflow.yaml +7 -11
- package/core/.specialist/Forseti/core.yaml +1 -1
- package/core/.specialist/Heimdall/SKILL.md +1 -1
- package/core/.specialist/Heimdall/core.yaml +2 -2
- package/core/.specialist/Huginn/rule.yaml +1 -1
- package/core/.specialist/tools/README.md +2 -2
- package/core/.specialist/tools/ansuz-boot-fast.js +20 -0
- package/core/.system/collaboration/README.md +1 -1
- package/core/.system/formation/LEGACY_SKILLS.md +2 -0
- package/core/.system/formation/POST_COMPLETE.md +2 -0
- package/core/.system/formation/README.md +2 -0
- package/core/KENAZ_CORE_VERSION +1 -1
- package/core/dev/scripts/check-file-size.js +44 -17
- package/core/k-cli/README.md +1 -94
- package/core/k-cli/test/TEST_SPEC.md +5 -22
- package/core/k-cli/test/run.sh +1 -67
- package/core/plugins/kenaz/commands/Ansuz.md +1 -0
- package/package.json +6 -6
- package/core/.formation/legion/forge.yaml +0 -299
- package/core/.formation/legion/hive.yaml +0 -238
- package/core/.formation/squad/blitz.yaml +0 -192
- package/core/.formation/squad/strategy-tribunal.yaml +0 -354
- package/core/.formation/squad/strategy.yaml +0 -349
- package/core/.formation/squad/sweep.yaml +0 -208
|
@@ -23,6 +23,7 @@ ARGUMENTS 含 `[專案: X]` 或 `--project-path X` → PROJECT_PATH = X,下方
|
|
|
23
23
|
- 有**對照組**:證明測試在舊程式上會失敗,或修改前後結果不同。
|
|
24
24
|
- subagent 回報「未驗證」的項目,由你補驗或明講仍未驗證。
|
|
25
25
|
- 合併:`git merge` 後跑相關測試(或 `node .specialist/Ansuz/tools/verify-and-land.js`),用這個專案自己的測試指令。
|
|
26
|
+
- 檔案長度:verify-and-land 合併前會跑 `check-file-size`(警告 1000 / 上限 2000 行,既有超長檔只擋「未配套拆分的增長」);被擋就按職責拆成 module / component / hook。
|
|
26
27
|
- 只在 Kenaz 核心 repo 本身:Rust 測試走 `npm run test:rust` / `dev/scripts/rust-verify-worktree.js`。
|
|
27
28
|
|
|
28
29
|
## 回報
|
|
@@ -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());
|
|
@@ -51,7 +51,7 @@ no surprise dependencies.
|
|
|
51
51
|
4. When the user picks an entry, the id is written into
|
|
52
52
|
`.kenaz/dashboard-models.json` via
|
|
53
53
|
`POST /api/dashboard/settings/models` and consumed by
|
|
54
|
-
`
|
|
54
|
+
`SpecialistPanel.handleLaunch`.
|
|
55
55
|
|
|
56
56
|
## Adding a new model
|
|
57
57
|
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
# Amelia 判斷 role 時參考此表,dispatch 時用此表解析 --role 路徑
|
|
4
4
|
#
|
|
5
5
|
# Usage:
|
|
6
|
-
# node .formation/tools/
|
|
7
|
-
#
|
|
6
|
+
# node .formation/tools/dispatch.js --skill=do --task=TASK_XXX --dispatch-mode=subagent
|
|
7
|
+
# (此表僅供 Amelia 判斷 role 參考;dispatch.js 不讀取它。headless-v2 已退役)
|
|
8
8
|
|
|
9
9
|
version: "2.0"
|
|
10
10
|
|
|
@@ -259,7 +259,7 @@ roles:
|
|
|
259
259
|
- 新功能開發
|
|
260
260
|
|
|
261
261
|
# ─────────────────────────────────────────
|
|
262
|
-
# Base Skills
|
|
262
|
+
# Base Skills(歷史:原 headless-v2 的 --base 選項)
|
|
263
263
|
# ─────────────────────────────────────────
|
|
264
264
|
|
|
265
265
|
bases:
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
#
|
|
5
5
|
# Consumers:
|
|
6
6
|
# A: .collaboration/core/task-api.js — full lifecycle (create/claim/complete/move/delete/get/list)
|
|
7
|
-
# B: .formation/tools/
|
|
7
|
+
# B: .formation/tools/dispatch.js — dispatch coordinator (no direct state mutation; triggers post-complete)
|
|
8
8
|
# C: .formation/tools/post-complete.js — close-out only (active→done or active→blocked; never creates)
|
|
9
9
|
# D: dashboard kenaz MCP tools (kenaz.rs) — CRUD subset only (list/get/create/update/delete via task-api CLI proxy)
|
|
10
10
|
#
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
# MCP does NOT have standalone claim, complete, or move tools — these are action values in tool_task_update.
|
|
14
14
|
# task-api.js (consumer A) owns the full state machine and all lifecycle transitions.
|
|
15
15
|
# post-complete.js (consumer C) is exclusively close-out; it calls task-api complete/move internally.
|
|
16
|
-
#
|
|
16
|
+
# dispatch.js (consumer B) coordinates dispatch and triggers post-complete on task completion.
|
|
17
17
|
#
|
|
18
18
|
# STATUS DUALITY (known divergence):
|
|
19
19
|
# Filesystem directory names (canonical): backlog, active, done, blocked, cancel, archived
|
|
@@ -153,7 +153,7 @@ operations:
|
|
|
153
153
|
post_complete_trigger: true
|
|
154
154
|
known_asymmetry:
|
|
155
155
|
- "moveTask(taskId, 'done') achieves same filesystem effect but does NOT trigger post-complete"
|
|
156
|
-
- "Consumer B (
|
|
156
|
+
- "Consumer B (dispatch.js) does not mutate task state itself; post-complete runs separately"
|
|
157
157
|
error_modes:
|
|
158
158
|
- "Task not found → throws"
|
|
159
159
|
- "post-complete failure → logged as warning; task already moved to done (non-atomic)"
|
|
@@ -95,8 +95,8 @@ setModels({ dispatchModel: 'gpt-5-codex', dispatchFamily: 'bifrost', dispatchAda
|
|
|
95
95
|
check('reject bifrost+adapter: no agentParams on stdout', !(r.stdout || '').includes('agentParams'), (r.stdout || '').slice(0, 200));
|
|
96
96
|
check('reject bifrost+adapter: invariant wording (refusing to silently dispatch)',
|
|
97
97
|
(r.stderr || '').includes('refusing to silently dispatch with the native Claude CLI'), (r.stderr || '').slice(-400));
|
|
98
|
-
check('reject bifrost+adapter: remediation
|
|
99
|
-
(r.stderr || '').includes('kenaz_dispatch'), (r.stderr || '').slice(-400));
|
|
98
|
+
check('reject bifrost+adapter: remediation tells the user to pick a Claude model',
|
|
99
|
+
(r.stderr || '').includes('Select a Claude model') && !(r.stderr || '').includes('kenaz_dispatch'), (r.stderr || '').slice(-400));
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
// ── [3] reject: dispatchFamily=bifrost, adapter missing/empty ───────────────
|
|
@@ -175,8 +175,8 @@ setModels({ dispatchModel: 'claude-nova-9', dispatchFamily: 'claude' });
|
|
|
175
175
|
check('unmappable model: loud stderr (refusing to silently guess)',
|
|
176
176
|
(r.stderr || '').includes("dispatchModel 'claude-nova-9' cannot be mapped") &&
|
|
177
177
|
(r.stderr || '').includes('refusing to silently guess'), (r.stderr || '').slice(-400));
|
|
178
|
-
check('unmappable model: remediation points
|
|
179
|
-
(r.stderr || '').includes('kenaz_dispatch'), (r.stderr || '').slice(-400));
|
|
178
|
+
check('unmappable model: remediation points at the model selection, not retired kenaz_dispatch',
|
|
179
|
+
(r.stderr || '').includes('Fix the model selection') && !(r.stderr || '').includes('kenaz_dispatch'), (r.stderr || '').slice(-400));
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
// ── [9]-[11] TASK_2032: subagent dispatch activates the card like the ────────
|
|
@@ -990,14 +990,14 @@ async function getSubagentDispatchParams(options) {
|
|
|
990
990
|
}
|
|
991
991
|
if (routing.family === 'bifrost' && !isCodex) {
|
|
992
992
|
if (!routing.adapter) {
|
|
993
|
-
console.error('❌ dashboard-models.json sets dispatchFamily=bifrost but dispatchAdapter is missing/empty — refusing to silently dispatch with the native Claude CLI. Fix the model selection (Quick Actions → Default Models) or dashboard-models.json
|
|
993
|
+
console.error('❌ dashboard-models.json sets dispatchFamily=bifrost but dispatchAdapter is missing/empty — refusing to silently dispatch with the native Claude CLI. Fix the model selection (Quick Actions → Default Models) or dashboard-models.json.');
|
|
994
994
|
} else {
|
|
995
|
-
console.error(`❌ dashboard-models.json sets dispatchFamily=bifrost (dispatchAdapter '${routing.adapter}') — sub-agent dispatch cannot run non-Claude models (the Agent tool only accepts Claude models); refusing to silently dispatch with the native Claude CLI.
|
|
995
|
+
console.error(`❌ dashboard-models.json sets dispatchFamily=bifrost (dispatchAdapter '${routing.adapter}') — sub-agent dispatch cannot run non-Claude models (the Agent tool only accepts Claude models); refusing to silently dispatch with the native Claude CLI. Select a Claude model in Default Models.`);
|
|
996
996
|
}
|
|
997
997
|
return null;
|
|
998
998
|
}
|
|
999
999
|
if (!isCodex && routing.family && routing.family !== 'claude') {
|
|
1000
|
-
console.error(`❌ dashboard-models.json sets unsupported dispatchFamily '${routing.family}' (expected 'claude' or 'bifrost') — refusing to silently dispatch with the native Claude CLI. Fix the model selection (Quick Actions → Default Models)
|
|
1000
|
+
console.error(`❌ dashboard-models.json sets unsupported dispatchFamily '${routing.family}' (expected 'claude' or 'bifrost') — refusing to silently dispatch with the native Claude CLI. Fix the model selection (Quick Actions → Default Models).`);
|
|
1001
1001
|
return null;
|
|
1002
1002
|
}
|
|
1003
1003
|
|
|
@@ -1017,7 +1017,7 @@ async function getSubagentDispatchParams(options) {
|
|
|
1017
1017
|
// TASK_2315: Codex inherits the session model — nothing to translate or pass.
|
|
1018
1018
|
const translated = isCodex ? { model: null, lossless: true } : translateModelForAgentTool(rawModel);
|
|
1019
1019
|
if (!isCodex && !translated.model) {
|
|
1020
|
-
console.error(`❌ ${resolution.label} '${rawModel}' cannot be mapped to an Agent tool model family (${AGENT_TOOL_MODEL_ENUM.join('|')}) — refusing to silently guess a substitute model. Fix the model selection (Quick Actions → Default Models)
|
|
1020
|
+
console.error(`❌ ${resolution.label} '${rawModel}' cannot be mapped to an Agent tool model family (${AGENT_TOOL_MODEL_ENUM.join('|')}) — refusing to silently guess a substitute model. Fix the model selection (Quick Actions → Default Models).`);
|
|
1021
1021
|
return null;
|
|
1022
1022
|
}
|
|
1023
1023
|
const dispatchModel = translated.model;
|
|
@@ -1025,7 +1025,7 @@ async function getSubagentDispatchParams(options) {
|
|
|
1025
1025
|
? { requested: rawModel, inherited: true }
|
|
1026
1026
|
: { requested: rawModel, agentToolModel: dispatchModel, granularityLoss: !translated.lossless };
|
|
1027
1027
|
if (!translated.lossless) {
|
|
1028
|
-
process.stderr.write(`[dispatch] model '${rawModel}' → Agent tool family '${dispatchModel}' — the Agent tool only expresses family names (${AGENT_TOOL_MODEL_ENUM.join('|')}); version/speed selections (e.g. 5 vs 4.x, [1m]) are NOT expressible on the subagent path. If the exact version matters,
|
|
1028
|
+
process.stderr.write(`[dispatch] model '${rawModel}' → Agent tool family '${dispatchModel}' — the Agent tool only expresses family names (${AGENT_TOOL_MODEL_ENUM.join('|')}); version/speed selections (e.g. 5 vs 4.x, [1m]) are NOT expressible on the subagent path. If the exact version matters, choose a model whose family the Agent tool can express.\n`);
|
|
1029
1029
|
}
|
|
1030
1030
|
// TASK_1998 R3 (+ two-axis policy): the resolved choice, always printed
|
|
1031
1031
|
// (unlike the granularity block above, which only fires on lossy translation).
|
|
@@ -171,6 +171,10 @@ function buildSubagentPrompt(skillDef, taskInput, taskData, options = {}) {
|
|
|
171
171
|
// Ponytail A/B 2026-10-10: the arm without this line broke an existing fixture test it never ran.
|
|
172
172
|
lines.push('Before editing, grep the callers, tests and fixtures your change reaches; fix what it breaks. Least code that completes the card: reuse existing helpers, no unrequested abstraction, option or duplicate implementation.');
|
|
173
173
|
lines.push('Run the tests the card names (narrow, not the full suite); say what you did not verify.');
|
|
174
|
+
// The worktree branches off the project's current branch; user projects are not always on `main`.
|
|
175
|
+
const sizeBase = (require('child_process').spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: options.projectPath || process.cwd(), encoding: 'utf8' }).stdout || '').trim() || 'main';
|
|
176
|
+
const sizeCorePath = options.corePath || resolveCorePath(options.projectPath) || path.resolve(__dirname, '..', '..');
|
|
177
|
+
lines.push(`Before reporting run \`node ${sizeCorePath}/dev/scripts/check-file-size.js --base ${sizeBase} --head HEAD\`; if it BLOCKs, split by responsibility (module / component / hook) first.`);
|
|
174
178
|
const onBlocked = skillDef.collaboration && skillDef.collaboration.on_blocked;
|
|
175
179
|
if (onBlocked && onBlocked.run) {
|
|
176
180
|
lines.push(`Blocked: \`${buildTaskApiRewriter(options)(onBlocked.run).replace(/\{task_id\}/g, taskId)}\` and explain.`);
|
|
@@ -48,10 +48,8 @@ collaboration:
|
|
|
48
48
|
- 根據 acceptance_criteria 驗證成果
|
|
49
49
|
# TASK_1819: 此處原有的 worktree_path_rule 欄位已移除 — 它是死欄位,
|
|
50
50
|
# prompt.js 的 appendWorkflow 只讀 step/run/note/actions,從未渲染它,
|
|
51
|
-
# 且其範例路徑早已過時。實際生效的 worktree 路徑規則由
|
|
52
|
-
#
|
|
53
|
-
# operations MUST use paths under: <worktreeDir>" 前綴,含具體路徑),
|
|
54
|
-
# Agent-tool 派工另由 prompt.js SUBAGENT_PREFIX 規則 11 覆蓋。
|
|
51
|
+
# 且其範例路徑早已過時。實際生效的 worktree 路徑規則由
|
|
52
|
+
# prompt.js SUBAGENT_PREFIX 規則 11 覆蓋(原 runner.js 注入已隨 headless 路徑移除)。
|
|
55
53
|
|
|
56
54
|
# 注意(TASK_1819):下方 run 的 git add -A 只會在隔離模式(worktree /
|
|
57
55
|
# subagent)原樣渲染;serial 共用工作樹模式下 prompt.js appendWorkflow
|
|
@@ -543,15 +543,15 @@ rules:
|
|
|
543
543
|
- id: "AGT-010"
|
|
544
544
|
title: "Dispatch 後切回 User Branch"
|
|
545
545
|
description: |
|
|
546
|
-
Formation headless
|
|
546
|
+
(已退役)Formation headless dispatch 曾在獨立進程中建立 task branch(task/task_XXX),
|
|
547
547
|
完成後 post-complete.js 會 merge 回 user branch。但呼叫者(Ansuz/Agent)的
|
|
548
548
|
working tree 仍停留在 task branch 上。
|
|
549
549
|
|
|
550
550
|
如果 dispatch 後繼續在同一 session 做其他改動,會意外 commit 到 task branch,
|
|
551
551
|
而不是 user branch。用戶在桌面端會看到 branch 不一致。
|
|
552
552
|
severity: "must"
|
|
553
|
-
rationale: "headless
|
|
554
|
-
trigger: "Formation dispatch
|
|
553
|
+
rationale: "headless dispatch 是獨立進程(已退役,僅供歷史參照),不會切換呼叫者的 git working tree"
|
|
554
|
+
trigger: "legacy Formation dispatch 完成後,Ansuz 或 Agent 要做下一個改動之前"
|
|
555
555
|
workflow:
|
|
556
556
|
- step: 1
|
|
557
557
|
action: "確認 dispatch 完成(task status = completed)"
|
|
@@ -568,5 +568,5 @@ rules:
|
|
|
568
568
|
❌ 多個 task 共用同一個 task branch(每個 task 應有自己的 branch)
|
|
569
569
|
applies_to:
|
|
570
570
|
- "Ansuz(PM dispatch 後繼續做其他工作)"
|
|
571
|
-
- "
|
|
571
|
+
- "任何仍經 legacy Formation dispatch 的 Agent"
|
|
572
572
|
- "用戶手動 dispatch 後"
|
|
@@ -9,8 +9,8 @@ rules:
|
|
|
9
9
|
Single-file growth is the primary vector for SRP violations in this codebase.
|
|
10
10
|
Enforce line-count thresholds to force extraction before files become unmaintainable.
|
|
11
11
|
thresholds:
|
|
12
|
-
warn:
|
|
13
|
-
hard_limit:
|
|
12
|
+
warn: 1000
|
|
13
|
+
hard_limit: 2000
|
|
14
14
|
scope:
|
|
15
15
|
include:
|
|
16
16
|
- "**/*.rs"
|
|
@@ -22,20 +22,32 @@ rules:
|
|
|
22
22
|
- "**/target/**"
|
|
23
23
|
- "**/node_modules/**"
|
|
24
24
|
- "**/*.test.*"
|
|
25
|
+
- "**/*_test.*"
|
|
26
|
+
- "**/tests/**"
|
|
27
|
+
- "**/__tests__/**"
|
|
28
|
+
- "**/*.generated.*"
|
|
25
29
|
- "**/*.spec.*"
|
|
26
30
|
- "**/*.d.ts"
|
|
27
31
|
- "**/generated/**"
|
|
28
32
|
enforcement:
|
|
29
33
|
on_task_design: |
|
|
30
|
-
When designing a task that adds >50 LOC to a file already above warn threshold (
|
|
34
|
+
When designing a task that adds >50 LOC to a file already above warn threshold (1000 lines):
|
|
31
35
|
1. Check current file size (wc -l or kenaz_lsp_symbols)
|
|
32
36
|
2. If file > warn: acceptance criteria MUST include "extract [new responsibility] into separate module"
|
|
33
37
|
3. If file > hard_limit: task MUST be split — one task for extraction, one for the new feature
|
|
34
38
|
Ansuz must enforce this at task design time, not defer to the implementing agent.
|
|
35
39
|
on_code_review: |
|
|
36
|
-
Any PR that increases a file past hard_limit (
|
|
40
|
+
Any PR that increases a file past hard_limit (2000 lines) without a paired extraction
|
|
37
41
|
commit is a Blocker-level review finding.
|
|
38
42
|
existing_debt: |
|
|
43
|
+
Decision 2026-10-10 (user, via Ansuz): warn 1000 / hard_limit 2000 lines for source
|
|
44
|
+
files; tests and generated files are excluded. Existing over-limit files are
|
|
45
|
+
GRANDFATHERED: dev/scripts/check-file-size.js only blocks growth of such a file (>50 LOC)
|
|
46
|
+
without a paired extraction (a new file in the same change), or a file newly crossing
|
|
47
|
+
hard_limit. Split by responsibility (module / component / hook) when you touch one.
|
|
48
|
+
Enforced in verify-and-land.js (file-size step, before merge) and the subagent
|
|
49
|
+
"Verify and report" prompt line. Without this yaml (user project) the script uses the
|
|
50
|
+
same defaults built in.
|
|
39
51
|
Files currently above hard_limit are tracked as architecture debt.
|
|
40
52
|
Extraction tasks should be created incrementally — do not attempt big-bang refactors.
|
|
41
53
|
Follow the three-stage pattern: build-map → plan-split → minimal-patch.
|
|
@@ -33,7 +33,6 @@ rules:
|
|
|
33
33
|
file: "dev/scripts/verify-target-dir.js"
|
|
34
34
|
consumers:
|
|
35
35
|
- "dev/scripts/run-rust-tests.js (defaults CARGO_TARGET_DIR when unset)"
|
|
36
|
-
- ".formation/tools/runner.js (worktree dispatch env, 2 call sites)"
|
|
37
36
|
- ".formation/tools/integration.js (in-lock re-verify env)"
|
|
38
37
|
shell_usage: "CARGO_TARGET_DIR=$(node dev/scripts/verify-target-dir.js) cargo check --lib -p kenaz-dashboard"
|
|
39
38
|
|