@inkobytes/nexus 1.0.0

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 (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +455 -0
  3. package/bin/nexus.js +108 -0
  4. package/drills/nexus-agent-protocol/README.md +65 -0
  5. package/drills/nexus-agent-protocol/cases/blocked.yaml +20 -0
  6. package/drills/nexus-agent-protocol/cases/claim-before-edit.yaml +16 -0
  7. package/drills/nexus-agent-protocol/cases/current-file-state.yaml +15 -0
  8. package/drills/nexus-agent-protocol/cases/data-boundary-table-header.yaml +21 -0
  9. package/drills/nexus-agent-protocol/cases/data-mutation-delete-rows.yaml +20 -0
  10. package/drills/nexus-agent-protocol/cases/done-claim-adversarial.yaml +18 -0
  11. package/drills/nexus-agent-protocol/cases/ghost-file-claim-loop.yaml +16 -0
  12. package/drills/nexus-agent-protocol/cases/issue-found.yaml +21 -0
  13. package/drills/nexus-agent-protocol/cases/private-path-protection.yaml +23 -0
  14. package/drills/nexus-agent-protocol/cases/queue-is-thin-index.yaml +21 -0
  15. package/drills/nexus-agent-protocol/cases/removal-scope.yaml +26 -0
  16. package/drills/nexus-agent-protocol/cases/remove-agent-folders-from-git.yaml +24 -0
  17. package/drills/nexus-agent-protocol/cases/stale-lock-after-commit.yaml +26 -0
  18. package/drills/nexus-agent-protocol/cases/start-does-not-replace-claim-release.yaml +17 -0
  19. package/drills/nexus-agent-protocol/cases/task-contract.yaml +23 -0
  20. package/drills/nexus-agent-protocol/cases/vendor-cleanup-preserve-history.yaml +24 -0
  21. package/drills/nexus-agent-protocol/cases/wrong-repo-push.yaml +23 -0
  22. package/nexus-dashboard/docs/index.html +183 -0
  23. package/nexus-dashboard/index.html +678 -0
  24. package/nexus-dashboard/logo-nexus.svg +14 -0
  25. package/nexus-dashboard/style.css +1454 -0
  26. package/package.json +42 -0
  27. package/skills/nexus/SKILL.md +62 -0
  28. package/src/commands/checkin.js +19 -0
  29. package/src/commands/checkout.js +33 -0
  30. package/src/commands/chmod.js +93 -0
  31. package/src/commands/claim.js +122 -0
  32. package/src/commands/clean.js +76 -0
  33. package/src/commands/dashboard.js +387 -0
  34. package/src/commands/db.js +256 -0
  35. package/src/commands/doctor.js +958 -0
  36. package/src/commands/drill.js +507 -0
  37. package/src/commands/help.js +8 -0
  38. package/src/commands/init.js +576 -0
  39. package/src/commands/ledger.js +215 -0
  40. package/src/commands/metrics.js +178 -0
  41. package/src/commands/next.js +317 -0
  42. package/src/commands/release.js +107 -0
  43. package/src/commands/soul.js +156 -0
  44. package/src/commands/standup.js +59 -0
  45. package/src/commands/start.js +126 -0
  46. package/src/commands/status.js +109 -0
  47. package/src/hooks/pre-migration-backup.js +35 -0
  48. package/src/lib/agentScopes.js +61 -0
  49. package/src/lib/blackboard.js +90 -0
  50. package/src/lib/config.js +38 -0
  51. package/src/lib/dump.js +63 -0
  52. package/src/lib/git.js +111 -0
  53. package/src/lib/lockManager.js +302 -0
  54. package/src/lib/pathSafety.js +41 -0
  55. package/src/lib/permissions.js +74 -0
@@ -0,0 +1,215 @@
1
+ /**
2
+ * nexus ledger [--json]
3
+ * Read completed task ledger entries for dashboard/reporting.
4
+ */
5
+
6
+ import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'fs';
7
+ import { getConfig } from '../lib/config.js';
8
+
9
+ export default function ledger(args) {
10
+ if (args[0] === 'backfill') {
11
+ const count = backfillLedger();
12
+ console.log(`Backfilled ${count} completed task entr${count === 1 ? 'y' : 'ies'} into _NEXUS_LEDGER.md.`);
13
+ return;
14
+ }
15
+
16
+ const entries = readLedgerEntries();
17
+
18
+ if (args.includes('--json')) {
19
+ console.log(JSON.stringify({ entries, totals: buildTotals(entries) }, null, 2));
20
+ return;
21
+ }
22
+
23
+ console.log('Nexus ledger');
24
+ console.log('');
25
+ if (!entries.length) {
26
+ console.log('No completed task entries yet.');
27
+ return;
28
+ }
29
+
30
+ for (const entry of entries.slice().reverse().slice(0, 20)) {
31
+ console.log(`- ${entry.completedAt} ${entry.id} (${entry.agent})`);
32
+ console.log(` ${entry.title}`);
33
+ console.log(` Epic: ${entry.epic || 'unknown'} | Cost: ${entry.cost || 'unknown'}`);
34
+ if (entry.files.length) console.log(` Files: ${entry.files.join(', ')}`);
35
+ }
36
+ }
37
+
38
+ export function appendCompletedLedgerEntries({ target, agent, sha, commit }) {
39
+ const config = getConfig();
40
+ const queueText = readText(config.queue);
41
+ const existingIds = new Set(readLedgerEntries().map((entry) => entry.id));
42
+ const tasks = parseQueueTasks(queueText)
43
+ .filter((task) => task.checked)
44
+ .filter((task) => task.id && !existingIds.has(task.id))
45
+ .filter((task) => task.files.includes(target))
46
+ .filter((task) => commitReferencesTask(commit, task.id));
47
+
48
+ if (!tasks.length) return 0;
49
+ ensureLedgerFile(config.ledger);
50
+
51
+ const completedAt = new Date().toISOString();
52
+ for (const task of tasks) {
53
+ appendFileSync(config.ledger, renderEntry({
54
+ ...task,
55
+ agent,
56
+ sha,
57
+ commit,
58
+ completedAt,
59
+ }), 'utf-8');
60
+ }
61
+
62
+ return tasks.length;
63
+ }
64
+
65
+ export function readLedgerEntries() {
66
+ const config = getConfig();
67
+ const content = readText(config.ledger);
68
+ const entries = [];
69
+ const blocks = content.split(/\n(?=## )/);
70
+
71
+ for (const block of blocks) {
72
+ const header = block.match(/^## ([^\n]+)$/m);
73
+ if (!header) continue;
74
+ entries.push({
75
+ id: readField(block, 'Id') || header[1].trim(),
76
+ title: readField(block, 'Title'),
77
+ agent: readField(block, 'Agent') || 'unknown',
78
+ epic: readField(block, 'Epic'),
79
+ cost: readField(block, 'Cost'),
80
+ completedAt: readField(block, 'Completed At'),
81
+ sha: readField(block, 'SHA'),
82
+ commit: readField(block, 'Commit'),
83
+ source: readField(block, 'Source') || 'release',
84
+ files: splitCsv(readField(block, 'Files')),
85
+ });
86
+ }
87
+
88
+ return entries;
89
+ }
90
+
91
+ export function backfillLedger() {
92
+ const config = getConfig();
93
+ const existingIds = new Set(readLedgerEntries().map((entry) => entry.id));
94
+ const tasks = parseQueueTasks(readText(config.queue))
95
+ .filter((task) => task.checked)
96
+ .filter((task) => task.id && !existingIds.has(task.id));
97
+
98
+ if (!tasks.length) return 0;
99
+ ensureLedgerFile(config.ledger);
100
+
101
+ const completedAt = new Date().toISOString();
102
+ for (const task of tasks) {
103
+ appendFileSync(config.ledger, renderEntry({
104
+ ...task,
105
+ agent: normalizeAgentForLedger(task.agent),
106
+ sha: 'unknown',
107
+ commit: `backfill: ${task.id}`,
108
+ completedAt,
109
+ source: 'backfill',
110
+ }), 'utf-8');
111
+ }
112
+
113
+ return tasks.length;
114
+ }
115
+
116
+ function buildTotals(entries) {
117
+ return {
118
+ completedTasks: entries.length,
119
+ byAgent: countBy(entries, 'agent'),
120
+ byEpic: countBy(entries, 'epic'),
121
+ byCost: countBy(entries, 'cost'),
122
+ };
123
+ }
124
+
125
+ function parseQueueTasks(content) {
126
+ const tasks = [];
127
+ const lines = content.split('\n');
128
+ let current = null;
129
+
130
+ for (const line of lines) {
131
+ const match = line.match(/^- \[([ x])\] TASK\/([^:]+):\s*(.+)$/);
132
+ if (match) {
133
+ if (current) tasks.push(current);
134
+ current = {
135
+ checked: match[1] === 'x',
136
+ agent: match[2].trim(),
137
+ title: match[3].trim(),
138
+ id: '',
139
+ epic: '',
140
+ files: [],
141
+ cost: '',
142
+ };
143
+ continue;
144
+ }
145
+
146
+ if (!current) continue;
147
+ const field = line.trim().match(/^- ([^:]+):\s*(.*)$/);
148
+ if (!field) continue;
149
+
150
+ const key = field[1].toLowerCase();
151
+ const value = field[2].trim();
152
+ if (key === 'id') current.id = value;
153
+ if (key === 'epic') current.epic = value;
154
+ if (key === 'files') current.files = splitCsv(value);
155
+ if (key === 'cost') current.cost = value;
156
+ }
157
+
158
+ if (current) tasks.push(current);
159
+ return tasks;
160
+ }
161
+
162
+ function ensureLedgerFile(path) {
163
+ if (existsSync(path)) return;
164
+ writeFileSync(path, '# Nexus Completed Task Ledger\n\n', 'utf-8');
165
+ }
166
+
167
+ function renderEntry(entry) {
168
+ return `## ${entry.id}
169
+
170
+ - Id: ${entry.id}
171
+ - Title: ${entry.title}
172
+ - Agent: ${entry.agent || 'unknown'}
173
+ - Epic: ${entry.epic || 'unknown'}
174
+ - Cost: ${entry.cost || 'unknown'}
175
+ - Completed At: ${entry.completedAt}
176
+ - Files: ${entry.files.join(', ')}
177
+ - SHA: ${entry.sha || 'unknown'}
178
+ - Commit: ${entry.commit || ''}
179
+ - Source: ${entry.source || 'release'}
180
+
181
+ `;
182
+ }
183
+
184
+ function readText(path) {
185
+ if (!existsSync(path)) return '';
186
+ return readFileSync(path, 'utf-8');
187
+ }
188
+
189
+ function readField(block, label) {
190
+ return block.match(new RegExp(`^- ${label}: (.*)$`, 'm'))?.[1]?.trim() || '';
191
+ }
192
+
193
+ function splitCsv(value) {
194
+ if (!value) return [];
195
+ return value.split(',').map((item) => item.trim()).filter(Boolean);
196
+ }
197
+
198
+ function commitReferencesTask(commit, taskId) {
199
+ const normalizedCommit = String(commit || '').toLowerCase();
200
+ return normalizedCommit.includes(taskId.toLowerCase());
201
+ }
202
+
203
+ function countBy(items, key) {
204
+ const counts = {};
205
+ for (const item of items) {
206
+ const value = item[key] || 'unknown';
207
+ counts[value] = (counts[value] || 0) + 1;
208
+ }
209
+ return counts;
210
+ }
211
+
212
+ function normalizeAgentForLedger(agent) {
213
+ const value = String(agent || '').trim();
214
+ return value.startsWith('@') ? value : `@${value.toLowerCase()}`;
215
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * nexus metrics [--json]
3
+ * Read-only summary of Nexus release and queue activity.
4
+ */
5
+
6
+ import { existsSync, readFileSync } from 'fs';
7
+ import { spawnSync } from 'child_process';
8
+ import { getConfig } from '../lib/config.js';
9
+
10
+ export default function metrics(args) {
11
+ const json = args.includes('--json');
12
+ const config = getConfig();
13
+ const reportText = readText(config.report);
14
+ const queueText = readText(config.queue);
15
+ const gitCommits = readGitCommits(config.root);
16
+ const releases = parseReport(reportText);
17
+ const summary = buildSummary(gitCommits, releases, queueText);
18
+
19
+ if (json) {
20
+ console.log(JSON.stringify(summary, null, 2));
21
+ return;
22
+ }
23
+
24
+ printSummary(summary);
25
+ }
26
+
27
+ function readText(path) {
28
+ if (!existsSync(path)) return '';
29
+ return readFileSync(path, 'utf-8');
30
+ }
31
+
32
+ function readGitCommits(root) {
33
+ const result = spawnSync('git', ['log', '--date=short', '--pretty=format:%H%x09%ad%x09%s'], {
34
+ cwd: root,
35
+ encoding: 'utf-8',
36
+ stdio: 'pipe',
37
+ });
38
+ if (result.status !== 0) return [];
39
+
40
+ return result.stdout.split('\n').filter(Boolean).map((line) => {
41
+ const [sha, date, ...subjectParts] = line.split('\t');
42
+ const subject = subjectParts.join('\t');
43
+ const match = subject.match(/^\[([^\]]+)\]\s+(.+)$/);
44
+ const rawAgent = match ? match[1] : '';
45
+ return {
46
+ sha,
47
+ date,
48
+ agent: normalizeAgentBucket(rawAgent),
49
+ subject: match ? match[2] : subject,
50
+ };
51
+ });
52
+ }
53
+
54
+ function parseReport(content) {
55
+ const releases = [];
56
+ const blocks = content.split(/\n(?=## \[\d\d:\d\d:\d\d\] )/);
57
+
58
+ for (const block of blocks) {
59
+ const header = block.match(/^## \[(\d\d:\d\d:\d\d)\] (.+)$/m);
60
+ if (!header) continue;
61
+ releases.push({
62
+ time: header[1],
63
+ target: readField(block, 'Target') || header[2],
64
+ agent: normalizeAgentBucket(readField(block, 'Agent')),
65
+ sha: readField(block, 'SHA') || '',
66
+ commit: readField(block, 'Commit') || '',
67
+ });
68
+ }
69
+
70
+ return releases;
71
+ }
72
+
73
+ function readField(block, label) {
74
+ return block.match(new RegExp(`^- ${label}: (.+)$`, 'm'))?.[1] || '';
75
+ }
76
+
77
+ function buildSummary(gitCommits, releases, queueText) {
78
+ return {
79
+ identityBuckets: {
80
+ 'legacy-agent': 'Older Nexus releases used the generic [Agent] commit or report label before explicit handles shipped.',
81
+ 'unknown-agent': 'No parseable agent label was found in the commit subject or report receipt.',
82
+ },
83
+ commitsByAgent: countBy(gitCommits, 'agent'),
84
+ releasesByAgent: countBy(releases, 'agent'),
85
+ topReleaseTargets: topCounts(countBy(releases, 'target'), 8),
86
+ weeklyVelocity: countBy(gitCommits, (commit) => weekKey(commit.date)),
87
+ queueCostDistribution: parseQueueCostDistribution(queueText),
88
+ totals: {
89
+ commits: gitCommits.length,
90
+ releases: releases.length,
91
+ },
92
+ };
93
+ }
94
+
95
+ function normalizeAgentBucket(agent) {
96
+ const value = String(agent || '').trim();
97
+ if (!value) return 'unknown-agent';
98
+ if (value.toLowerCase() === 'unknown') return 'unknown-agent';
99
+ if (value.toLowerCase() === 'agent') return 'legacy-agent';
100
+ return value;
101
+ }
102
+
103
+ function countBy(items, keyOrFn) {
104
+ const counts = {};
105
+ for (const item of items) {
106
+ const key = typeof keyOrFn === 'function' ? keyOrFn(item) : item[keyOrFn];
107
+ if (!key) continue;
108
+ counts[key] = (counts[key] || 0) + 1;
109
+ }
110
+ return counts;
111
+ }
112
+
113
+ function topCounts(counts, limit) {
114
+ return Object.entries(counts)
115
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
116
+ .slice(0, limit)
117
+ .map(([name, count]) => ({ name, count }));
118
+ }
119
+
120
+ function weekKey(dateText) {
121
+ if (!dateText) return 'unknown';
122
+ const date = new Date(`${dateText}T00:00:00Z`);
123
+ if (Number.isNaN(date.getTime())) return 'unknown';
124
+ const day = date.getUTCDay() || 7;
125
+ date.setUTCDate(date.getUTCDate() - day + 1);
126
+ return date.toISOString().slice(0, 10);
127
+ }
128
+
129
+ function parseQueueCostDistribution(content) {
130
+ const counts = {};
131
+ for (const line of content.split('\n')) {
132
+ const match = line.trim().match(/^- Cost:\s*(.+)$/);
133
+ if (!match) continue;
134
+ const cost = match[1];
135
+ counts[cost] = (counts[cost] || 0) + 1;
136
+ }
137
+ return counts;
138
+ }
139
+
140
+ function printSummary(summary) {
141
+ console.log('Nexus metrics');
142
+ console.log('');
143
+ console.log(`Totals: ${summary.totals.commits} commits, ${summary.totals.releases} release receipt(s)`);
144
+ printCounts('Commits by agent', summary.commitsByAgent);
145
+ printCounts('Releases by agent', summary.releasesByAgent);
146
+ printIdentityNotes(summary);
147
+ printTopTargets(summary.topReleaseTargets);
148
+ printCounts('Weekly velocity', summary.weeklyVelocity);
149
+ printCounts('Queue cost distribution', summary.queueCostDistribution);
150
+ }
151
+
152
+ function printIdentityNotes(summary) {
153
+ const used = Object.keys(summary.identityBuckets)
154
+ .filter((bucket) => summary.commitsByAgent[bucket] || summary.releasesByAgent[bucket]);
155
+ if (!used.length) return;
156
+
157
+ console.log('\nIdentity bucket notes');
158
+ for (const bucket of used) console.log(` ${bucket}: ${summary.identityBuckets[bucket]}`);
159
+ }
160
+
161
+ function printCounts(title, counts) {
162
+ console.log(`\n${title}`);
163
+ const rows = Object.entries(counts).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
164
+ if (!rows.length) {
165
+ console.log(' none');
166
+ return;
167
+ }
168
+ for (const [name, count] of rows) console.log(` ${name}: ${count}`);
169
+ }
170
+
171
+ function printTopTargets(targets) {
172
+ console.log('\nTop release targets');
173
+ if (!targets.length) {
174
+ console.log(' none');
175
+ return;
176
+ }
177
+ for (const target of targets) console.log(` ${target.name}: ${target.count}`);
178
+ }
@@ -0,0 +1,317 @@
1
+ /**
2
+ * nexus next <agent>
3
+ * Budget-aware task suggestion from the ready queue.
4
+ */
5
+
6
+ import { readFileSync, existsSync } from 'fs';
7
+ import { getConfig } from '../lib/config.js';
8
+ import { readBoard } from '../lib/blackboard.js';
9
+ import { spawnSync } from 'child_process';
10
+
11
+ export default function next(args) {
12
+ const agent = args[0];
13
+
14
+ if (!agent) {
15
+ console.error('Usage: nexus next <agent_name>');
16
+ process.exit(1);
17
+ }
18
+
19
+ const config = getConfig();
20
+
21
+ // Read queue file
22
+ if (!existsSync(config.queue)) {
23
+ console.log('No _NEXUS_QUEUE.md found. Nothing to suggest.');
24
+ return;
25
+ }
26
+
27
+ const queueContent = readFileSync(config.queue, 'utf-8');
28
+ const boardContent = readBoard();
29
+
30
+ // Parse runway for this agent
31
+ const runway = parseRunway(queueContent, agent);
32
+
33
+ // Parse ready tasks
34
+ const tasks = parseReadyTasks(queueContent);
35
+
36
+ if (tasks.length === 0) {
37
+ console.log(`📋 No Ready tasks in queue. Standby.`);
38
+ return;
39
+ }
40
+
41
+ // Get currently claimed files from blackboard
42
+ const claimedFiles = parseClaimed(boardContent);
43
+
44
+ // Load budget if available
45
+ const budget = loadBudget(config.budgetFile, agent);
46
+
47
+ // Score and filter tasks — only Ready Queue, only human-approved auto-flow
48
+ const candidates = tasks
49
+ .filter(t => t.status === 'Ready')
50
+ .filter(t => t.autoFlow === 'yes')
51
+ .filter(t => t.review === 'approved')
52
+ .filter(t => !hasFileConflict(t.files, claimedFiles))
53
+ .filter(t => dependenciesMet(t.dependsOn, tasks, config.root))
54
+ .filter(t => t.cost !== 'spiky')
55
+ .filter(t => fitsbudget(t.cost, budget));
56
+
57
+ if (candidates.length === 0) {
58
+ console.log(`📋 No safe auto-flow tasks available for ${agent}. Standby.`);
59
+ return;
60
+ }
61
+
62
+ // Prefer same-runway, lower cost
63
+ const scored = candidates.map(t => ({
64
+ ...t,
65
+ score: scoreTask(t, runway),
66
+ })).sort((a, b) => b.score - a.score);
67
+
68
+ const pick = scored[0];
69
+ console.log(`\nNEXUS SUGGESTS for ${agent}:`);
70
+ console.log(` Task: ${pick.id}`);
71
+ console.log(` Epic: ${pick.epic}`);
72
+ console.log(` Files: ${pick.files.join(', ')}`);
73
+ console.log(` Cost: ${pick.cost}`);
74
+ console.log(` Auto-flow: ${pick.autoFlow}`);
75
+ printRelatedDrills(pick);
76
+ console.log('');
77
+ }
78
+
79
+ const DATA_MUTATION_DRILL = `data-mutation-${'delete-rows'}`;
80
+
81
+ const DRILL_GROUPS = [
82
+ {
83
+ group: 'data',
84
+ hints: [
85
+ { id: DATA_MUTATION_DRILL, keywords: ['db', 'database', 'migration', 'persisted'] },
86
+ { id: 'data-boundary-table-header', keywords: ['table header', 'table headers', 'columns', 'csv headers', 'schema'] },
87
+ ],
88
+ },
89
+ {
90
+ group: 'removal',
91
+ hints: [
92
+ { id: 'vendor-cleanup-preserve-history', keywords: ['payment vendor', 'audit log', 'audit logs', 'historical records', 'all traces'] },
93
+ { id: 'removal-scope', keywords: ['vendor', 'dependency', 'legacy integration', 'remove', 'cleanup'] },
94
+ { id: 'task-contract', keywords: ['completely', 'broad task', 'remove all', 'all traces', 'migration'] },
95
+ ],
96
+ },
97
+ {
98
+ group: 'publish',
99
+ hints: [
100
+ { id: 'private-path-protection', keywords: ['publish', 'npm', 'package', 'public', 'privacy', 'private'] },
101
+ { id: 'remove-agent-folders-from-git', keywords: ['untrack', 'gitignore', '.codex', '.claude', '.gemini', '.agy', '.nexus/local', 'user.md'] },
102
+ ],
103
+ },
104
+ {
105
+ group: 'git',
106
+ hints: [
107
+ { id: 'wrong-repo-push', keywords: ['push', 'remote', 'github', 'origin'] },
108
+ { id: 'stale-lock-after-commit', keywords: ['stale lock', 'stale locks', 'nexus clean', 'lock cleanup'] },
109
+ ],
110
+ },
111
+ {
112
+ group: 'protocol',
113
+ hints: [
114
+ { id: 'queue-is-thin-index', keywords: ['_nexus_queue.md', 'queue', 'task plan', 'handoff'] },
115
+ { id: 'current-file-state', keywords: ['current file', 'edited', 'existing file', 'stale context'] },
116
+ { id: 'ghost-file-claim-loop', keywords: ['pre-claim', 'claim loop', 'fresh file state'] },
117
+ { id: 'claim-before-edit', keywords: ['readme', 'docs', 'edit', 'update', 'modify'] },
118
+ { id: 'start-does-not-replace-claim-release', keywords: ['nexus start', 'start then edit'] },
119
+ { id: 'done-claim-adversarial', keywords: ['done', 'validated', 'verification', 'release'] },
120
+ ],
121
+ },
122
+ ];
123
+
124
+ function printRelatedDrills(task) {
125
+ const drills = relatedDrillsForTask(task);
126
+ if (drills.length === 0) return;
127
+
128
+ console.log('');
129
+ console.log(' Related Drills:');
130
+ for (const id of drills) {
131
+ console.log(` - ${id}`);
132
+ }
133
+ console.log(' Run `nexus drill show <id>` if the task matches that risk.');
134
+ }
135
+
136
+ function relatedDrillsForTask(task) {
137
+ if (task.drills.length > 0) return task.drills;
138
+
139
+ const haystack = [
140
+ task.title,
141
+ task.id,
142
+ task.epic,
143
+ task.dependsOn,
144
+ task.files.join(' '),
145
+ task.affinity.join(' '),
146
+ task.notes,
147
+ ].join(' ').toLowerCase();
148
+
149
+ const matches = [];
150
+ for (const group of DRILL_GROUPS) {
151
+ for (const hint of group.hints) {
152
+ if (hint.keywords.some(keyword => haystack.includes(keyword))) {
153
+ matches.push(hint.id);
154
+ }
155
+ }
156
+ }
157
+ return [...new Set(matches)];
158
+ }
159
+
160
+ function parseRunway(content, agent) {
161
+ const lines = content.split('\n');
162
+ for (const line of lines) {
163
+ if (line.includes(agent) && line.includes('->')) {
164
+ return line
165
+ .split(':').slice(1).join(':')
166
+ .split('->')
167
+ .map(s => s.trim());
168
+ }
169
+ }
170
+ return [];
171
+ }
172
+
173
+ function extractSection(content, heading) {
174
+ const lines = content.split('\n');
175
+ let inSection = false;
176
+ const result = [];
177
+ for (const line of lines) {
178
+ if (line.startsWith('## ')) {
179
+ inSection = line.trim() === heading;
180
+ continue;
181
+ }
182
+ if (inSection) result.push(line);
183
+ }
184
+ return result.join('\n');
185
+ }
186
+
187
+ function parseReadyTasks(content) {
188
+ // Only read from ## Ready Queue — Proposed section is invisible to nexus next
189
+ const sectionContent = extractSection(content, '## Ready Queue');
190
+ const tasks = [];
191
+ const lines = sectionContent.split('\n');
192
+ let current = null;
193
+
194
+ for (const line of lines) {
195
+ const taskMatch = line.match(/^- \[[ x]\] TASK\/.+?:\s*(.+)/);
196
+ if (taskMatch) {
197
+ if (current) tasks.push(current);
198
+ current = {
199
+ title: taskMatch[1],
200
+ id: '',
201
+ epic: '',
202
+ status: '',
203
+ dependsOn: '',
204
+ files: [],
205
+ affinity: [],
206
+ drills: [],
207
+ cost: 'medium',
208
+ autoFlow: 'no',
209
+ review: '',
210
+ approvedBy: '',
211
+ notes: '',
212
+ };
213
+ continue;
214
+ }
215
+
216
+ if (current && line.match(/^\s+-\s/)) {
217
+ const kv = line.trim().replace(/^-\s*/, '');
218
+ const colonIdx = kv.indexOf(':');
219
+ if (colonIdx === -1) continue;
220
+
221
+ const key = kv.slice(0, colonIdx).trim().toLowerCase();
222
+ const val = kv.slice(colonIdx + 1).trim();
223
+
224
+ switch (key) {
225
+ case 'id': current.id = val; break;
226
+ case 'epic': current.epic = val; break;
227
+ case 'status': current.status = val; break;
228
+ case 'depends on': current.dependsOn = val; break;
229
+ case 'files': current.files = val.split(',').map(s => s.trim()); break;
230
+ case 'affinity': current.affinity = val.split(',').map(s => s.trim()); break;
231
+ case 'drills': current.drills = val.split(',').map(s => s.trim()).filter(Boolean); break;
232
+ case 'cost': current.cost = val; break;
233
+ case 'auto-flow': current.autoFlow = val; break;
234
+ case 'review': current.review = val.toLowerCase(); break;
235
+ case 'approved by': current.approvedBy = val; break;
236
+ case 'notes': current.notes = val; break;
237
+ }
238
+ }
239
+ }
240
+
241
+ if (current) tasks.push(current);
242
+ return tasks;
243
+ }
244
+
245
+ function parseClaimed(boardContent) {
246
+ return boardContent
247
+ .split('\n')
248
+ .filter(l => l.includes('🔒'))
249
+ .map(l => {
250
+ const match = l.match(/\*\*(.+?)\*\*/);
251
+ return match ? match[1] : '';
252
+ })
253
+ .filter(Boolean);
254
+ }
255
+
256
+ function hasFileConflict(taskFiles, claimedFiles) {
257
+ for (const tf of taskFiles) {
258
+ for (const cf of claimedFiles) {
259
+ if (tf.startsWith(cf) || cf.startsWith(tf)) return true;
260
+ }
261
+ }
262
+ return false;
263
+ }
264
+
265
+ function dependenciesMet(dep, allTasks, root) {
266
+ if (!dep || dep === 'none') return true;
267
+
268
+ // Check if it's a task ID
269
+ const depTask = allTasks.find(t => t.id === dep);
270
+ if (depTask) return depTask.status === 'Done';
271
+
272
+ // Check if it's a git commit ref
273
+ const result = spawnSync('git', ['cat-file', '-t', dep], {
274
+ cwd: root,
275
+ encoding: 'utf-8',
276
+ stdio: 'pipe',
277
+ });
278
+ return result.status === 0;
279
+ }
280
+
281
+ function fitsbudget(cost, budget) {
282
+ if (!budget) return true; // no budget file = no constraint
283
+
284
+ const costMap = { small: 5, medium: 15, large: 30, spiky: 50 };
285
+ const taskCost = costMap[cost] || 15;
286
+ const remaining = budget.session_budget - budget.used_session;
287
+
288
+ return taskCost <= remaining;
289
+ }
290
+
291
+ function loadBudget(budgetFile, agent) {
292
+ if (!existsSync(budgetFile)) return null;
293
+
294
+ try {
295
+ const data = JSON.parse(readFileSync(budgetFile, 'utf-8'));
296
+ return data[agent] || null;
297
+ } catch {
298
+ return null;
299
+ }
300
+ }
301
+
302
+ function scoreTask(task, runway) {
303
+ let score = 0;
304
+
305
+ // Runway position bonus (earlier = better)
306
+ const runwayIdx = runway.findIndex(r =>
307
+ task.epic.toLowerCase().includes(r.toLowerCase()) ||
308
+ r.toLowerCase().includes(task.epic.toLowerCase())
309
+ );
310
+ if (runwayIdx >= 0) score += (10 - runwayIdx);
311
+
312
+ // Cost preference (smaller = safer)
313
+ const costScore = { small: 3, medium: 2, large: 1, spiky: 0 };
314
+ score += costScore[task.cost] || 0;
315
+
316
+ return score;
317
+ }