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.
Files changed (48) hide show
  1. package/core/.claude/commands/Ansuz.md +1 -0
  2. package/core/.collaboration/core/reconcile.js +193 -0
  3. package/core/.collaboration/core/task-2404-reconcile.test.js +135 -0
  4. package/core/.collaboration/core/task-api.js +27 -1
  5. package/core/.formation/native/claude/README.md +1 -1
  6. package/core/.formation/role-map.yaml +3 -3
  7. package/core/.formation/spec/task_operations.yaml +3 -3
  8. package/core/.formation/tools/__tests__/subagent-bifrost-guard.test.js +4 -4
  9. package/core/.formation/tools/dispatch.js +5 -5
  10. package/core/.formation/tools/prompt.js +4 -0
  11. package/core/.formation/unit/do.yaml +2 -4
  12. package/core/.rule/general/agent-workflow.yaml +4 -4
  13. package/core/.rule/general/file-size-threshold.yaml +16 -4
  14. package/core/.rule/general/rust-verify-target.yaml +0 -1
  15. package/core/.rule/general/system-invariants.yaml +8 -25
  16. package/core/.specialist/Ansuz/SYSTEM_DOC.md +17 -42
  17. package/core/.specialist/Ansuz/capability.yaml +7 -14
  18. package/core/.specialist/Ansuz/concepts/formation_quick_ref.yaml +4 -11
  19. package/core/.specialist/Ansuz/elixir.yaml +3 -4
  20. package/core/.specialist/Ansuz/identity_contract.yaml +1 -1
  21. package/core/.specialist/Ansuz/knowledge.yaml +7 -10
  22. package/core/.specialist/Ansuz/rule.yaml +2 -2
  23. package/core/.specialist/Ansuz/tools/verify-and-land.js +22 -0
  24. package/core/.specialist/Ansuz/tools/verify-and-land.test.js +59 -5
  25. package/core/.specialist/Ansuz/workflow.yaml +7 -11
  26. package/core/.specialist/Forseti/core.yaml +1 -1
  27. package/core/.specialist/Heimdall/SKILL.md +1 -1
  28. package/core/.specialist/Heimdall/core.yaml +2 -2
  29. package/core/.specialist/Huginn/rule.yaml +1 -1
  30. package/core/.specialist/tools/README.md +2 -2
  31. package/core/.specialist/tools/ansuz-boot-fast.js +20 -0
  32. package/core/.system/collaboration/README.md +1 -1
  33. package/core/.system/formation/LEGACY_SKILLS.md +2 -0
  34. package/core/.system/formation/POST_COMPLETE.md +2 -0
  35. package/core/.system/formation/README.md +2 -0
  36. package/core/KENAZ_CORE_VERSION +1 -1
  37. package/core/dev/scripts/check-file-size.js +44 -17
  38. package/core/k-cli/README.md +1 -94
  39. package/core/k-cli/test/TEST_SPEC.md +5 -22
  40. package/core/k-cli/test/run.sh +1 -67
  41. package/core/plugins/kenaz/commands/Ansuz.md +1 -0
  42. package/package.json +6 -6
  43. package/core/.formation/legion/forge.yaml +0 -299
  44. package/core/.formation/legion/hive.yaml +0 -238
  45. package/core/.formation/squad/blitz.yaml +0 -192
  46. package/core/.formation/squad/strategy-tribunal.yaml +0 -354
  47. package/core/.formation/squad/strategy.yaml +0 -349
  48. 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 === '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());
@@ -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
- `kenaz_dispatch` / `SpecialistPanel.handleLaunch`.
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/headless-v2.js --base=do --role=developer --task=TASK_XXX
7
- # headless-v2 會查此表找到 skill_path → 載入該 YAML 的 role context
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(headless-v2 的 --base 選項)
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/headless.js — dispatch coordinator (no direct state mutation; triggers post-complete)
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
- # headless.js (consumer B) coordinates dispatch and triggers post-complete on task completion.
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 (headless.js) bypasses via FORMATION_HEADLESS=1 and calls post-complete separately"
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 points to Formation dispatch (kenaz_dispatch)',
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 to Formation dispatch (kenaz_dispatch)',
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, then use Formation dispatch (kenaz_dispatch).');
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. Use Formation dispatch (kenaz_dispatch) instead — it routes through the Bifrost adapter.`);
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) or use Formation dispatch (kenaz_dispatch).`);
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) or use Formation dispatch (kenaz_dispatch), which accepts full model ids via --dispatch-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).`);
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, use Formation dispatch (kenaz_dispatch), which passes full model ids.\n`);
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 路徑規則由 runner.js 在
52
- # spawn 時注入(spawnClaude/spawnBifrost 的 "[WORKTREE] All file
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.js dispatch 會在獨立進程中建立 task branch(task/task_XXX),
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.js 是獨立進程,不會切換呼叫者的 git working tree"
554
- trigger: "Formation dispatch(headless.js)完成後,Ansuz 或 Agent 要做下一個改動之前"
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
- - "任何使用 headless.js 的 Agent"
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: 2000
13
- hard_limit: 4000
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 (2000 lines):
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 (4000 lines) without a paired extraction
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