@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,90 @@
1
+ /**
2
+ * Blackboard — serialized read/write to _NEXUS.md
3
+ * All writes go through a lockfile to prevent race conditions
4
+ * between agents writing simultaneously.
5
+ */
6
+
7
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmdirSync } from 'fs';
8
+ import { resolve } from 'path';
9
+ import { getConfig } from './config.js';
10
+
11
+ function getLockPath(blackboardPath) {
12
+ return blackboardPath + '.lockdir';
13
+ }
14
+
15
+ function acquireLock(blackboardPath, maxAttempts = 25, intervalMs = 200) {
16
+ const lockPath = getLockPath(blackboardPath);
17
+ let attempts = 0;
18
+
19
+ while (attempts < maxAttempts) {
20
+ try {
21
+ mkdirSync(lockPath);
22
+ return true;
23
+ } catch {
24
+ attempts++;
25
+ if (attempts >= maxAttempts) {
26
+ console.error('[WARN] Blackboard write lock timeout');
27
+ return false;
28
+ }
29
+ const start = Date.now();
30
+ while (Date.now() - start < intervalMs) { /* spin */ }
31
+ }
32
+ }
33
+ return false;
34
+ }
35
+
36
+ function releaseLock(blackboardPath) {
37
+ const lockPath = getLockPath(blackboardPath);
38
+ try {
39
+ rmdirSync(lockPath);
40
+ } catch { /* already gone */ }
41
+ }
42
+
43
+ export function withLock(fn) {
44
+ const config = getConfig();
45
+ const boardPath = resolve(config.root, '_NEXUS.md');
46
+
47
+ if (!acquireLock(boardPath)) {
48
+ throw new Error('Could not acquire blackboard lock');
49
+ }
50
+
51
+ try {
52
+ return fn(boardPath);
53
+ } finally {
54
+ releaseLock(boardPath);
55
+ }
56
+ }
57
+
58
+ export function readBoard() {
59
+ const config = getConfig();
60
+ const boardPath = resolve(config.root, '_NEXUS.md');
61
+
62
+ if (!existsSync(boardPath)) return '';
63
+ return readFileSync(boardPath, 'utf-8');
64
+ }
65
+
66
+ export function appendEntry(line) {
67
+ withLock((boardPath) => {
68
+ if (!existsSync(boardPath)) writeFileSync(boardPath, '', 'utf-8');
69
+ const content = readFileSync(boardPath, 'utf-8');
70
+ writeFileSync(boardPath, content + line + '\n', 'utf-8');
71
+ });
72
+ }
73
+
74
+ export function removeEntry(needle) {
75
+ withLock((boardPath) => {
76
+ if (!existsSync(boardPath)) return;
77
+ const content = readFileSync(boardPath, 'utf-8');
78
+ const filtered = content
79
+ .split('\n')
80
+ .filter(line => !line.includes(needle))
81
+ .join('\n');
82
+ writeFileSync(boardPath, filtered, 'utf-8');
83
+ });
84
+ }
85
+
86
+ export function clearBoard() {
87
+ withLock((boardPath) => {
88
+ writeFileSync(boardPath, '', 'utf-8');
89
+ });
90
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Config — resolve project root and Nexus paths
3
+ */
4
+
5
+ import { existsSync } from 'fs';
6
+ import { resolve, join } from 'path';
7
+ import { cwd } from 'process';
8
+
9
+ let _config = null;
10
+
11
+ export function getConfig(fromDir) {
12
+ if (_config) return _config;
13
+
14
+ const root = fromDir || cwd();
15
+ const lockDir = join(root, '.nexus', 'locks');
16
+ const budgetFile = join(root, '.nexus', 'agent-budgets.json');
17
+
18
+ _config = {
19
+ root,
20
+ lockDir,
21
+ budgetFile,
22
+ blackboard: join(root, '_NEXUS.md'),
23
+ standup: join(root, '_NEXUS_STANDUP.md'),
24
+ report: join(root, '_NEXUS_REPORT.md'),
25
+ ledger: join(root, '_NEXUS_LEDGER.md'),
26
+ queue: join(root, '_NEXUS_QUEUE.md'),
27
+ staleThreshold: 600, // 10 minutes in seconds
28
+ maxDumpFiles: 20,
29
+ maxClaimAttempts: 10,
30
+ claimRetryMs: 2000,
31
+ };
32
+
33
+ return _config;
34
+ }
35
+
36
+ export function resetConfig() {
37
+ _config = null;
38
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * File state dumper — outputs fresh file contents after a claim
3
+ * to override stale agent context.
4
+ */
5
+
6
+ import { existsSync, statSync, readFileSync, readdirSync } from 'fs';
7
+ import { join, resolve } from 'path';
8
+ import { getConfig } from './config.js';
9
+ import { normalizeTarget } from './pathSafety.js';
10
+
11
+ /**
12
+ * Recursively collect files in a directory
13
+ */
14
+ function collectFiles(dir, files = []) {
15
+ const entries = readdirSync(dir, { withFileTypes: true });
16
+ for (const entry of entries) {
17
+ const fullPath = join(dir, entry.name);
18
+ if (entry.isDirectory()) {
19
+ collectFiles(fullPath, files);
20
+ } else {
21
+ files.push(fullPath);
22
+ }
23
+ }
24
+ return files.sort();
25
+ }
26
+
27
+ /**
28
+ * Dump file state for a target (file or directory).
29
+ * Returns the output string.
30
+ */
31
+ export function dumpState(target) {
32
+ const config = getConfig();
33
+ const safeTarget = normalizeTarget(target);
34
+ const absoluteTarget = resolve(config.root, safeTarget);
35
+ const lines = [];
36
+
37
+ lines.push('--- START OF FRESH FILE STATE ---');
38
+
39
+ if (!existsSync(absoluteTarget)) {
40
+ lines.push('(New file/dir — does not exist yet)');
41
+ } else if (statSync(absoluteTarget).isDirectory()) {
42
+ const files = collectFiles(absoluteTarget);
43
+
44
+ if (files.length > config.maxDumpFiles) {
45
+ lines.push(`[WARN] Directory contains ${files.length} files. Showing first ${config.maxDumpFiles}.`);
46
+ for (const f of files.slice(0, config.maxDumpFiles)) {
47
+ lines.push(`=== ${f} ===`);
48
+ lines.push(readFileSync(f, 'utf-8'));
49
+ }
50
+ lines.push(`=== ... and ${files.length - config.maxDumpFiles} more files ===`);
51
+ } else {
52
+ for (const f of files) {
53
+ lines.push(`=== ${f} ===`);
54
+ lines.push(readFileSync(f, 'utf-8'));
55
+ }
56
+ }
57
+ } else {
58
+ lines.push(readFileSync(absoluteTarget, 'utf-8'));
59
+ }
60
+
61
+ lines.push('--- END OF FRESH FILE STATE ---');
62
+ return lines.join('\n');
63
+ }
package/src/lib/git.js ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Git helper — stage + commit with pathspec safety
3
+ */
4
+
5
+ import { existsSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { spawnSync } from 'child_process';
8
+ import { getConfig } from './config.js';
9
+ import { normalizeTarget } from './pathSafety.js';
10
+
11
+ function sleep(ms) {
12
+ const start = Date.now();
13
+ while (Date.now() - start < ms) { /* wait */ }
14
+ }
15
+
16
+ function isIndexLockError(message) {
17
+ return message.includes('.git/index.lock') || message.includes('index.lock');
18
+ }
19
+
20
+ function git(args) {
21
+ const result = spawnSync('git', args, {
22
+ cwd: getConfig().root,
23
+ encoding: 'utf-8',
24
+ stdio: 'pipe',
25
+ });
26
+
27
+ if (result.status !== 0) {
28
+ const detail = result.stderr?.trim() || result.stdout?.trim() || `git ${args[0]} failed`;
29
+ throw new Error(detail);
30
+ }
31
+
32
+ return result.stdout || '';
33
+ }
34
+
35
+ function gitWithIndexLockRetry(args, maxRetries, retryMs) {
36
+ let attempt = 0;
37
+ let lastError;
38
+
39
+ while (attempt < maxRetries) {
40
+ try {
41
+ return git(args);
42
+ } catch (err) {
43
+ lastError = err;
44
+ const indexLockPath = join(getConfig().root, '.git', 'index.lock');
45
+ const message = err.message || '';
46
+ if (!isIndexLockError(message) && !existsSync(indexLockPath)) throw err;
47
+ attempt++;
48
+ if (attempt < maxRetries) sleep(retryMs);
49
+ }
50
+ }
51
+
52
+ throw new Error(`Git index stayed locked after ${maxRetries} attempts. Another git process may still be running; retry the release when it finishes. Last error: ${lastError?.message || 'unknown error'}`);
53
+ }
54
+
55
+ /**
56
+ * Stage and commit a target (file or directory).
57
+ * Uses `git add` + `git commit --` pathspec to prevent stowaways.
58
+ */
59
+ export function stageAndCommit(target, message, maxRetries = 5, retryMs = 1000) {
60
+ const safeTarget = normalizeTarget(target);
61
+ let agent = '';
62
+
63
+ if (typeof maxRetries === 'string') {
64
+ agent = maxRetries.trim();
65
+ maxRetries = 5;
66
+ retryMs = 1000;
67
+ } else if (typeof retryMs === 'string') {
68
+ agent = retryMs.trim();
69
+ retryMs = 1000;
70
+ }
71
+
72
+ const attribution = agent ? agent : 'Agent';
73
+
74
+ // Stage the target
75
+ try {
76
+ gitWithIndexLockRetry(['add', '--', safeTarget], maxRetries, retryMs);
77
+ } catch (err) {
78
+ return { success: false, message: err.message || 'Git add failed.' };
79
+ }
80
+
81
+ let attempt = 0;
82
+ while (attempt < maxRetries) {
83
+ try {
84
+ gitWithIndexLockRetry(['commit', '-m', `[${attribution}] ${message}`, '--', safeTarget], maxRetries, retryMs);
85
+ const sha = git(['rev-parse', 'HEAD']).trim();
86
+ return { success: true, sha };
87
+ } catch (err) {
88
+ attempt++;
89
+ if (attempt >= maxRetries) {
90
+ // Check if there's actually nothing to commit
91
+ try {
92
+ const status = git(['status', '--porcelain', '--', safeTarget]);
93
+ if (!status.trim()) {
94
+ return { success: true, message: 'Nothing to commit (clean)' };
95
+ }
96
+ } catch { /* fall through */ }
97
+
98
+ return {
99
+ success: false,
100
+ message: err.message?.includes('Git index stayed locked')
101
+ ? err.message
102
+ : `Git commit failed after ${maxRetries} attempts.`,
103
+ };
104
+ }
105
+ // Brief pause before retry
106
+ sleep(retryMs);
107
+ }
108
+ }
109
+
110
+ return { success: false, message: 'Git commit failed.' };
111
+ }
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Lock Manager — atomic mkdir-based file/directory locking
3
+ * with hierarchy enforcement and stale detection.
4
+ */
5
+
6
+ import { spawnSync } from 'child_process';
7
+ import { mkdirSync, rmdirSync, existsSync, readFileSync, writeFileSync, readdirSync, statSync, unlinkSync } from 'fs';
8
+ import { join, dirname } from 'path';
9
+ import { getConfig } from './config.js';
10
+ import { lockNameToTarget, normalizeTarget, targetToLockName } from './pathSafety.js';
11
+
12
+ export function getLockPath(target) {
13
+ const config = getConfig();
14
+ return join(config.lockDir, targetToLockName(target));
15
+ }
16
+
17
+ /**
18
+ * Check for parent directory locks (upward traversal)
19
+ */
20
+ function checkParentLocks(target, lockDir) {
21
+ let parent = dirname(target);
22
+ while (parent !== '.' && parent !== '/') {
23
+ const parentLock = join(lockDir, targetToLockName(parent));
24
+ if (existsSync(parentLock)) {
25
+ return parent;
26
+ }
27
+ parent = dirname(parent);
28
+ }
29
+ return null;
30
+ }
31
+
32
+ /**
33
+ * Check for child file locks inside a directory (downward check)
34
+ */
35
+ function checkChildLocks(target, lockDir) {
36
+ const normalizedTarget = normalizeTarget(target);
37
+
38
+ if (!existsSync(lockDir)) return null;
39
+
40
+ const locks = readdirSync(lockDir);
41
+ for (const lock of locks) {
42
+ const lockPath = join(lockDir, lock);
43
+ if (!existsSync(lockPath)) continue;
44
+ try { if (!statSync(lockPath).isDirectory()) continue; } catch { continue; }
45
+
46
+ let lockedTarget = '';
47
+ try {
48
+ lockedTarget = lockNameToTarget(lock);
49
+ } catch {
50
+ continue;
51
+ }
52
+
53
+ if (lockedTarget.startsWith(`${normalizedTarget}/`) && lockedTarget !== normalizedTarget) {
54
+ return lockedTarget;
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+
60
+ /**
61
+ * Check if a lock is stale (older than threshold)
62
+ */
63
+ function isStale(lockPath) {
64
+ const config = getConfig();
65
+ const tsFile = join(lockPath, 'ts');
66
+
67
+ if (!existsSync(tsFile)) return false;
68
+
69
+ const lockTs = parseInt(readFileSync(tsFile, 'utf-8').trim(), 10);
70
+ const now = Math.floor(Date.now() / 1000);
71
+ const age = now - lockTs;
72
+
73
+ return age >= config.staleThreshold ? age : false;
74
+ }
75
+
76
+ /**
77
+ * Break a stale lock
78
+ */
79
+ const LOCK_METADATA_FILES = [
80
+ 'ts',
81
+ 'agent',
82
+ 'intent',
83
+ 'subagents',
84
+ 'model',
85
+ 'thinking',
86
+ 'verified',
87
+ 'trust-source',
88
+ 'claim-head',
89
+ ];
90
+
91
+ export function readGitHead(root) {
92
+ const result = spawnSync('git', ['rev-parse', 'HEAD'], {
93
+ cwd: root,
94
+ encoding: 'utf-8',
95
+ stdio: ['ignore', 'pipe', 'ignore'],
96
+ });
97
+ const head = result.stdout?.trim();
98
+ return result.status === 0 && head ? head : 'unknown';
99
+ }
100
+
101
+ function breakLock(lockPath) {
102
+ for (const f of LOCK_METADATA_FILES) {
103
+ try { unlinkSync(join(lockPath, f)); } catch { /* ok */ }
104
+ }
105
+ try { rmdirSync(lockPath); } catch { /* ok */ }
106
+ }
107
+
108
+ function detectTrust() {
109
+ if (process.env.CLAUDECODE === '1') return { verified: true, trustSource: 'harness' };
110
+ if (process.env.NEXUS_AGENT) return { verified: true, trustSource: 'operator' };
111
+ return { verified: false, trustSource: 'unverified' };
112
+ }
113
+
114
+ /**
115
+ * Attempt to acquire a lock on a file or directory.
116
+ * Returns { success, message, staleAge? }
117
+ */
118
+ export function acquireLock(target, agentName, intent, subagents = 0, metadata = {}) {
119
+ const config = getConfig();
120
+ const normalizedTarget = normalizeTarget(target);
121
+ const lockPath = getLockPath(normalizedTarget);
122
+
123
+ // Ensure lock directory exists
124
+ mkdirSync(config.lockDir, { recursive: true });
125
+
126
+ // Hierarchy check: parent locked?
127
+ const lockedParent = checkParentLocks(normalizedTarget, config.lockDir);
128
+ if (lockedParent) {
129
+ return {
130
+ success: false,
131
+ message: `Cannot claim '${normalizedTarget}' — parent dir '${lockedParent}' is locked by another agent.`,
132
+ };
133
+ }
134
+
135
+ // Hierarchy check: child locked? (only for directory claims)
136
+ try {
137
+ if (existsSync(normalizedTarget) && statSync(normalizedTarget).isDirectory()) {
138
+ const lockedChild = checkChildLocks(normalizedTarget, config.lockDir);
139
+ if (lockedChild) {
140
+ return {
141
+ success: false,
142
+ message: `Cannot claim '${normalizedTarget}' — a file inside it is already locked: ${lockedChild}`,
143
+ };
144
+ }
145
+ }
146
+ } catch { /* target might not exist yet, that's fine */ }
147
+
148
+ // Stale lock detection
149
+ if (existsSync(lockPath)) {
150
+ const staleAge = isStale(lockPath);
151
+ if (staleAge) {
152
+ console.log(`[WARN] Stale lock detected (${staleAge}s old). Auto-breaking.`);
153
+ breakLock(lockPath);
154
+ }
155
+ }
156
+
157
+ // Attempt atomic lock via mkdir
158
+ let attempts = 0;
159
+ while (attempts < config.maxClaimAttempts) {
160
+ try {
161
+ mkdirSync(lockPath);
162
+
163
+ // Write timestamp, agent, intent, and subagents for stale detection and dashboard display
164
+ writeFileSync(join(lockPath, 'ts'), String(Math.floor(Date.now() / 1000)), 'utf-8');
165
+ writeFileSync(join(lockPath, 'agent'), agentName || '', 'utf-8');
166
+ writeFileSync(join(lockPath, 'intent'), intent || '', 'utf-8');
167
+ if (subagents > 0) writeFileSync(join(lockPath, 'subagents'), String(subagents), 'utf-8');
168
+ if (metadata.model) writeFileSync(join(lockPath, 'model'), metadata.model, 'utf-8');
169
+ if (metadata.thinking) writeFileSync(join(lockPath, 'thinking'), metadata.thinking, 'utf-8');
170
+ const { verified, trustSource } = detectTrust();
171
+ writeFileSync(join(lockPath, 'verified'), verified ? 'true' : 'false', 'utf-8');
172
+ writeFileSync(join(lockPath, 'trust-source'), trustSource, 'utf-8');
173
+ writeFileSync(join(lockPath, 'claim-head'), readGitHead(config.root), 'utf-8');
174
+
175
+ return {
176
+ success: true,
177
+ message: `[LOCK ACQUIRED] - ${agentName} is clear to modify ${normalizedTarget}`,
178
+ };
179
+ } catch {
180
+ attempts++;
181
+ if (attempts >= config.maxClaimAttempts) {
182
+ return {
183
+ success: false,
184
+ message: `File locked by another agent. Failed after ${attempts * config.claimRetryMs / 1000}s.`,
185
+ };
186
+ }
187
+ const start = Date.now();
188
+ while (Date.now() - start < config.claimRetryMs) { /* spin wait */ }
189
+ }
190
+ }
191
+
192
+ return { success: false, message: 'Lock acquisition failed.' };
193
+ }
194
+
195
+ /**
196
+ * Release a lock. Returns { success, message }
197
+ */
198
+ export function releaseLock(target) {
199
+ const normalizedTarget = normalizeTarget(target);
200
+ const lockPath = getLockPath(normalizedTarget);
201
+
202
+ if (!existsSync(lockPath)) {
203
+ return { success: false, message: `No lock found for: ${normalizedTarget}` };
204
+ }
205
+
206
+ for (const f of LOCK_METADATA_FILES) {
207
+ try { unlinkSync(join(lockPath, f)); } catch { /* ok */ }
208
+ }
209
+ try {
210
+ rmdirSync(lockPath);
211
+ return { success: true, message: `Lock released for: ${normalizedTarget}` };
212
+ } catch (err) {
213
+ return { success: false, message: `Failed to release lock: ${err.message}` };
214
+ }
215
+ }
216
+
217
+ /**
218
+ * List all current locks with metadata
219
+ */
220
+ export function listLocks() {
221
+ const config = getConfig();
222
+ if (!existsSync(config.lockDir)) return [];
223
+
224
+ const locks = [];
225
+ const entries = readdirSync(config.lockDir);
226
+
227
+ for (const entry of entries) {
228
+ if (!entry.endsWith('.lock')) continue;
229
+ const lockPath = join(config.lockDir, entry);
230
+ try {
231
+ if (!statSync(lockPath).isDirectory()) continue;
232
+ } catch { continue; }
233
+
234
+ let target;
235
+ try { target = lockNameToTarget(entry); } catch { continue; }
236
+ const tsFile = join(lockPath, 'ts');
237
+ let age = null;
238
+
239
+ if (existsSync(tsFile)) {
240
+ const lockTs = parseInt(readFileSync(tsFile, 'utf-8').trim(), 10);
241
+ age = Math.floor(Date.now() / 1000) - lockTs;
242
+ }
243
+
244
+ const readMeta = (name) => {
245
+ try { return readFileSync(join(lockPath, name), 'utf-8').trim(); } catch { return ''; }
246
+ };
247
+
248
+ const subagentsRaw = readMeta('subagents');
249
+ locks.push({
250
+ target,
251
+ age,
252
+ lockPath,
253
+ agent: readMeta('agent'),
254
+ intent: readMeta('intent'),
255
+ subagents: subagentsRaw ? parseInt(subagentsRaw, 10) : 0,
256
+ model: readMeta('model'),
257
+ thinking: readMeta('thinking'),
258
+ verified: readMeta('verified') === 'true',
259
+ trustSource: readMeta('trust-source') || 'unverified',
260
+ claimHead: readMeta('claim-head') || 'unknown',
261
+ });
262
+ }
263
+
264
+ return locks;
265
+ }
266
+
267
+ /**
268
+ * Find and break all stale locks. Returns array of broken lock targets.
269
+ */
270
+ export function breakStaleLocks() {
271
+ const config = getConfig();
272
+ const broken = [];
273
+
274
+ for (const lock of listLocks()) {
275
+ if (lock.age !== null && lock.age >= config.staleThreshold) {
276
+ breakLock(lock.lockPath);
277
+ broken.push({ target: lock.target, age: lock.age });
278
+ }
279
+ }
280
+
281
+ return broken;
282
+ }
283
+
284
+ /**
285
+ * Nuke all locks
286
+ */
287
+ export function nukeAllLocks() {
288
+ const config = getConfig();
289
+ if (!existsSync(config.lockDir)) return;
290
+
291
+ const entries = readdirSync(config.lockDir);
292
+ for (const entry of entries) {
293
+ const lockPath = join(config.lockDir, entry);
294
+ try {
295
+ if (statSync(lockPath).isDirectory()) {
296
+ const tsFile = join(lockPath, 'ts');
297
+ try { unlinkSync(tsFile); } catch { /* ok */ }
298
+ rmdirSync(lockPath);
299
+ }
300
+ } catch { /* ok */ }
301
+ }
302
+ }
@@ -0,0 +1,41 @@
1
+ import { isAbsolute, relative, resolve, sep } from 'path';
2
+ import { getConfig } from './config.js';
3
+
4
+ const RESERVED_ROOTS = new Set(['.git', '.nexus']);
5
+
6
+ export function normalizeTarget(target) {
7
+ if (typeof target !== 'string' || !target.trim()) {
8
+ throw new Error('Missing target path.');
9
+ }
10
+
11
+ const raw = target.trim();
12
+ if (isAbsolute(raw)) {
13
+ throw new Error('Target must be a relative path inside the repo.');
14
+ }
15
+
16
+ const root = resolve(getConfig().root);
17
+ const absoluteTarget = resolve(root, raw);
18
+ const relativeTarget = relative(root, absoluteTarget);
19
+
20
+ if (!relativeTarget || relativeTarget.startsWith('..') || isAbsolute(relativeTarget)) {
21
+ throw new Error('Target must stay inside the repo.');
22
+ }
23
+
24
+ const normalized = relativeTarget.split(sep).join('/');
25
+ const firstPart = normalized.split('/')[0];
26
+
27
+ if (RESERVED_ROOTS.has(firstPart)) {
28
+ throw new Error(`Target cannot be inside ${firstPart}.`);
29
+ }
30
+
31
+ return normalized;
32
+ }
33
+
34
+ export function targetToLockName(target) {
35
+ return `${encodeURIComponent(normalizeTarget(target)).replaceAll('%', '~')}.lock`;
36
+ }
37
+
38
+ export function lockNameToTarget(lockName) {
39
+ const base = lockName.replace(/\.lock$/, '').replaceAll('~', '%');
40
+ return normalizeTarget(decodeURIComponent(base));
41
+ }