@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,958 @@
1
+ /**
2
+ * nexus doctor - inspect and repair agent protocol scaffolds in existing repos
3
+ */
4
+
5
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { cwd } from 'process';
8
+ import { spawnSync } from 'child_process';
9
+ import { listLocks } from '../lib/lockManager.js';
10
+ import { getConfig } from '../lib/config.js';
11
+ import { AGENT_SCOPE_LIST } from '../lib/agentScopes.js';
12
+ import { DEFAULT_MATRIX, loadPermissions, getChmodPath } from '../lib/permissions.js';
13
+
14
+ const MONTH_NAMES = [
15
+ 'January',
16
+ 'February',
17
+ 'March',
18
+ 'April',
19
+ 'May',
20
+ 'June',
21
+ 'July',
22
+ 'August',
23
+ 'September',
24
+ 'October',
25
+ 'November',
26
+ 'December',
27
+ ];
28
+
29
+ const START_MARKER = '<!-- NEXUS-AGENT-PROTOCOL:START -->';
30
+ const END_MARKER = '<!-- NEXUS-AGENT-PROTOCOL:END -->';
31
+
32
+ const CONTINUITY_TEMPLATE = `# CONTINUITY
33
+ Goal: Project setup
34
+ State: Planning
35
+
36
+ Now: Initial Nexus setup
37
+ Next: Confirm first task
38
+ Blockers: None
39
+ Decisions:
40
+ - Nexus manages swarm coordination
41
+ - Continuity and memories are agent-local
42
+ Files:
43
+ - _NEXUS_QUEUE.md
44
+ - _NEXUS_STANDUP.md
45
+ `;
46
+
47
+ const LOCAL_DECISIONS_TEMPLATE = `# Decisions
48
+
49
+ Local agent work decisions live here. This file is gitignored by Nexus.
50
+ `;
51
+
52
+ const LOCAL_GITIGNORE_LINES = ['DECISIONS.md', 'docs-priv/', '.nexus/presence/'];
53
+ const STANDUP_FORMAT_GUIDANCE = 'YYYY-MM-DD HH:MM AM/PM @agent [STATUS]: message';
54
+ const STANDUP_RULES_LINE = `*Rules: Append new entries at the bottom. One line per message. Use \`${STANDUP_FORMAT_GUIDANCE}\` so relevance is visible. Use 🧵 for long discussions.*`;
55
+
56
+ const MEMORY_INDEX_TEMPLATE = `# Memory Index
57
+
58
+ Newest first, max 10 visible entries.
59
+
60
+ Format:
61
+
62
+ - YYYY-Month/YYYY-MM-DD-HHMM-topic.md - short session label
63
+
64
+ Entries live in month folders from the start, for example:
65
+
66
+ - \`2026-January/2026-01-15-1030-project-setup.md\`
67
+ - \`2026-February/2026-02-01-0900-debug-session.md\`
68
+
69
+ This keeps monthly review simple: ask an agent to read one month folder and summarize the Markdown files.
70
+
71
+ `;
72
+
73
+ function currentMemoryMonthFolder(now = new Date()) {
74
+ return `${now.getFullYear()}-${MONTH_NAMES[now.getMonth()]}`;
75
+ }
76
+
77
+ function protocolBlock(agent) {
78
+ return `${START_MARKER}
79
+
80
+ ## Nexus Project Protocol
81
+
82
+ This project uses Nexus for multi-agent coordination.
83
+
84
+ ### Start Here
85
+
86
+ 1. Read \`_NEXUS_CONSTITUTION.md\`.
87
+ 2. Read \`_NEXUS_QUEUE.md\` for executable priorities.
88
+ 3. Read \`_NEXUS_STANDUP.md\` for comms, decisions, and completion notes.
89
+ 4. Read \`USER.md\` if present for local human preferences.
90
+ 5. Read \`${agent.continuity}\` for current session state.
91
+ 6. Read \`${agent.memoryIndex}\` and the latest memory entry when resync is needed.
92
+
93
+ ### Nexus Rules
94
+
95
+ - Claim before editing shared project files: \`nexus claim <path> @Agent "intent"\`.
96
+ - Release finished work through Nexus: \`nexus release <path> "commit message"\`.
97
+ - Use \`nexus next @Agent\` for the next safe queue task.
98
+ - Do not free-roam into unassigned or \`Auto-flow: no\` work without user approval.
99
+ - Direct user instruction can override queue order, but not claim/release, data, security, or approval gates.
100
+ - If no safe task remains, announce \`Standby\` with what you are waiting for, then stop until user input, queue change, or explicit assignment.
101
+
102
+ ### Current File State
103
+
104
+ - Treat previous chat context, cached model memory, and earlier reads as stale when file contents matter.
105
+ - Before claiming what a file says, making edits, or judging current state, read the file from disk with a fresh command.
106
+ - Treat \`nexus claim\` as the atomic lock-and-read boundary and its output as fresh file state for the claimed path.
107
+ - If you read a shared file before claiming it, treat that read as stale after claim succeeds.
108
+ - If another agent or tool may have touched the file since your last read, re-read it before editing.
109
+ - If a claim appears stale, do not edit through it; run \`nexus status\` or \`nexus doctor\`, then clean only when ownership is clearly abandoned.
110
+
111
+ ### Drills
112
+
113
+ Drill guidance is defined in \`_NEXUS_CONSTITUTION.md\`.
114
+ If the situation resembles a drill, use that drill before acting.
115
+
116
+ ### Delegated Work
117
+
118
+ - Lead agents own the repo effects of their subagents, tools, and parallel workers.
119
+ - Claim the full path scope before delegating shared-file work.
120
+ - Give subagents the claimed path, intent, non-goals, and boundaries.
121
+ - Re-read affected files after subagent work before final edits, release, or current-state claims.
122
+ - Mention delegated work in release or \`nexus standup\` notes when it affected files, tests, or risk.
123
+
124
+ ### Git Write Safety
125
+
126
+ - Before git writes, verify \`pwd\`, repo root, branch/status, and remotes.
127
+ - Stop if they do not match the requested project.
128
+ - Never infer from similar folder names or cached context.
129
+ - Require explicit confirmation before push/force-push, main/master, remote changes, or deletes.
130
+ - To remove private agent files from git, untrack them; do not delete local folders.
131
+ - Agent instruction files are shared protocol files; normal edits require claim/release, while \`nexus doctor --fix\` may update managed protocol blocks after user approval.
132
+ - Agents work inside assigned work zones. If a change crosses work-zone boundaries or alters a shared contract another zone may depend on, announce it in \`_NEXUS_STANDUP.md\` before release and ask if coordination is needed.
133
+
134
+ ### Supply-Chain Safety
135
+
136
+ - Do not install third-party packages that have existed for less than 14 days.
137
+ - Before adding a new dependency, verify the package creation date and the specific version publish date.
138
+ - If the package or version is younger than 14 days, or either date cannot be verified, stop and ask the user.
139
+ - Run \`nexus doctor\` before installs; review any Security findings before running package scripts.
140
+ - \`nexus doctor\` is cheap, local, and idempotent.
141
+ - If \`nexus doctor\` reports Security, Package Privacy, Git Privacy, or supply-chain findings, stop and report before fixing or installing.
142
+ - Treat install hooks and scripts with network commands, webhooks, raw sockets, SSH, or secret-looking variables as human-review only.
143
+ - Prefer built-in runtime APIs and existing project dependencies when they fit.
144
+
145
+ ### Agent-Local Files
146
+
147
+ \`${agent.continuity}\` and \`${agent.memoryIndex}\` are agent-local handoff files.
148
+ They are exempt from Nexus claim/release unless the user says otherwise.
149
+
150
+ ### Memory Flow
151
+
152
+ - On session start, read \`${agent.memoryIndex}\`.
153
+ - If the index has entries, read the newest \`${agent.memoryDir}/YYYY-Month/YYYY-MM-DD-HHMM-topic.md\` entry.
154
+ - Durable architecture and protocol decisions belong in \`DECISIONS.md\`; mention them in \`_NEXUS_STANDUP.md\` only when active agents need to coordinate around them.
155
+ - Memory entries are session handoffs.
156
+ - When writing your own memory entry, create the current month folder under \`${agent.memoryDir}\` if it is missing.
157
+ - Do not create or repair other agents' memory folders manually; use \`nexus doctor --fix\` for broad scaffold repair.
158
+ - On session end, pause, or checkpoint request:
159
+ 1. Run \`nexus checkout @${agent.aliases[0]}\` to clear your presence heartbeat.
160
+ 2. Create one new memory file: \`${agent.memoryDir}/YYYY-Month/YYYY-MM-DD-HHMM-topic.md\`.
161
+ - Add the newest file to the top of \`${agent.memoryIndex}\`.
162
+ - Keep the index to the 10 newest visible entries.
163
+ - For monthly review, read one month folder such as \`${agent.memoryDir}/2026-January/\` and summarize the Markdown files.
164
+
165
+ Memory entry format:
166
+
167
+ \`\`\`markdown
168
+ # YYYY-MM-DD-HHMM - <topic>
169
+
170
+ ## Session Summary
171
+ - What we worked on: [<=50 words]
172
+ - What got done: [bullet list, max 5]
173
+ - Where we stopped: [exact state, <=30 words]
174
+
175
+ ## Next Session Needs
176
+ - Immediate next task: [<=20 words]
177
+ - Blockers: [None, or list]
178
+ - Open questions: [if any]
179
+
180
+ ## Context to Carry
181
+ - Key decisions made: [max 3 bullets]
182
+ - Files touched: [max 5 paths]
183
+ - Gotchas/warnings: [anything next session should watch for]
184
+ \`\`\`
185
+
186
+ ${END_MARKER}
187
+ `;
188
+ }
189
+
190
+ function fullEntrypoint(agent) {
191
+ return `# ${agent.label} Agent Guide
192
+
193
+ ${protocolBlock(agent)}`;
194
+ }
195
+
196
+ function upsertProtocolBlock(content, block) {
197
+ const cleanContent = removeUnmanagedProtocolBlock(content);
198
+ const start = cleanContent.indexOf(START_MARKER);
199
+ const end = cleanContent.indexOf(END_MARKER);
200
+
201
+ if (start !== -1 && end !== -1 && end > start) {
202
+ const before = cleanContent.slice(0, start).trimEnd();
203
+ const after = cleanContent.slice(end + END_MARKER.length).trimStart();
204
+ return `${before}\n\n${block.trim()}\n${after ? `\n${after}` : ''}`;
205
+ }
206
+
207
+ const unmanagedRange = findUnmanagedProtocolRange(cleanContent);
208
+ if (unmanagedRange) {
209
+ const before = cleanContent.slice(0, unmanagedRange.start).trimEnd();
210
+ const after = cleanContent.slice(unmanagedRange.end).trimStart();
211
+ return `${before}\n\n${block.trim()}\n${after ? `\n${after}` : ''}`;
212
+ }
213
+
214
+ return `${cleanContent.trimEnd()}\n\n${block.trim()}\n`;
215
+ }
216
+
217
+ function findUnmanagedProtocolRange(content) {
218
+ const protocolIntro = 'This project uses Nexus for multi-agent coordination.';
219
+ let searchFrom = 0;
220
+
221
+ const unmanagedMarkers = [
222
+ '\n## Start Here',
223
+ '\n## Nexus Rules',
224
+ '\n## Supply-Chain Safety',
225
+ '\n## Agent-Local Files',
226
+ '\n## Memory Flow',
227
+ 'Memory entry format:',
228
+ ];
229
+
230
+ while (searchFrom < content.length) {
231
+ const start = content.indexOf(protocolIntro, searchFrom);
232
+ if (start === -1) return null;
233
+
234
+ const nextManagedBlock = content.indexOf(START_MARKER, start);
235
+ const sectionEnd = nextManagedBlock === -1 ? content.length : nextManagedBlock;
236
+ const section = content.slice(start, sectionEnd);
237
+ if (!unmanagedMarkers.every((marker) => section.includes(marker))) {
238
+ searchFrom = start + protocolIntro.length;
239
+ continue;
240
+ }
241
+
242
+ const memoryFormatStart = content.indexOf('Memory entry format:', start);
243
+ if (memoryFormatStart === -1 || memoryFormatStart > sectionEnd) {
244
+ searchFrom = start + protocolIntro.length;
245
+ continue;
246
+ }
247
+
248
+ const codeFenceStart = content.indexOf('```markdown', memoryFormatStart);
249
+ if (codeFenceStart === -1 || codeFenceStart > sectionEnd) {
250
+ searchFrom = start + protocolIntro.length;
251
+ continue;
252
+ }
253
+
254
+ const codeFenceEnd = content.indexOf('\n```', codeFenceStart + '```markdown'.length);
255
+ if (codeFenceEnd === -1 || codeFenceEnd > sectionEnd) {
256
+ searchFrom = start + protocolIntro.length;
257
+ continue;
258
+ }
259
+
260
+ return {
261
+ start,
262
+ end: codeFenceEnd + '\n```'.length,
263
+ };
264
+ }
265
+
266
+ return null;
267
+ }
268
+
269
+ function hasUnmanagedProtocolBlock(content) {
270
+ return findUnmanagedProtocolRange(content) !== null;
271
+ }
272
+
273
+ function hasCurrentManagedProtocolBlock(content, block) {
274
+ const start = content.indexOf(START_MARKER);
275
+ const end = content.indexOf(END_MARKER);
276
+ if (start === -1 || end === -1 || end <= start) return false;
277
+
278
+ const existingBlock = content.slice(start, end + END_MARKER.length).trim();
279
+ return existingBlock === block.trim();
280
+ }
281
+
282
+ function removeUnmanagedProtocolBlock(content) {
283
+ const unmanagedRange = findUnmanagedProtocolRange(content);
284
+ if (!unmanagedRange) return content;
285
+
286
+ const before = content.slice(0, unmanagedRange.start).trimEnd();
287
+ const after = content.slice(unmanagedRange.end).trimStart();
288
+ return `${before}${before && after ? '\n\n' : ''}${after}`;
289
+ }
290
+
291
+ function ensureDir(path, fix, changes) {
292
+ if (existsSync(path)) return true;
293
+ if (!fix) return false;
294
+ mkdirSync(path, { recursive: true });
295
+ changes.push(`created ${path}`);
296
+ return true;
297
+ }
298
+
299
+ function ensureFile(path, content, fix, changes) {
300
+ if (existsSync(path)) return true;
301
+ if (!fix) return false;
302
+ writeFileSync(path, content, 'utf-8');
303
+ changes.push(`created ${path}`);
304
+ return true;
305
+ }
306
+
307
+ function ensureGitignoreLines(root, lines, fix, changes) {
308
+ const path = join(root, '.gitignore');
309
+ const existing = existsSync(path) ? readFileSync(path, 'utf-8') : '';
310
+ const missing = lines.filter((line) => !existing.split(/\r?\n/).includes(line));
311
+ if (missing.length === 0) return true;
312
+ if (!fix) return false;
313
+
314
+ const prefix = existing && !existing.endsWith('\n') ? '\n' : '';
315
+ const heading = existing.includes('# Nexus local state') ? '' : `${prefix}# Nexus local state\n`;
316
+ const next = `${existing}${heading}${missing.join('\n')}\n`;
317
+ writeFileSync(path, next, 'utf-8');
318
+ changes.push('updated .gitignore');
319
+ return true;
320
+ }
321
+
322
+ function repairStandupGuidance(content) {
323
+ if (content.includes(STANDUP_FORMAT_GUIDANCE)) return content;
324
+
325
+ const newline = content.includes('\r\n') ? '\r\n' : '\n';
326
+ const lines = content.split(/\r?\n/);
327
+ const rulesIndex = lines.findIndex((line) => line.includes('*Rules:') && line.includes('@agent'));
328
+
329
+ if (rulesIndex !== -1) {
330
+ lines[rulesIndex] = STANDUP_RULES_LINE;
331
+ return lines.join(newline);
332
+ }
333
+
334
+ const firstHeadingIndex = lines.findIndex((line) => line.trim().startsWith('#'));
335
+ if (firstHeadingIndex !== -1) {
336
+ lines.splice(firstHeadingIndex + 1, 0, '', STANDUP_RULES_LINE);
337
+ return lines.join(newline);
338
+ }
339
+
340
+ const trimmed = content.trimEnd();
341
+ return `${trimmed}${trimmed ? `${newline}${newline}` : ''}${STANDUP_RULES_LINE}${newline}`;
342
+ }
343
+
344
+ export default function doctor(args) {
345
+ const fix = args.includes('--fix');
346
+ const json = args.includes('--json');
347
+ const root = cwd();
348
+ const sections = {
349
+ 'Nexus Files': [],
350
+ 'Agent Instructions': [],
351
+ Security: [],
352
+ 'Package Privacy': [],
353
+ 'Git Privacy': [],
354
+ 'Legacy Helpers': [],
355
+ Continuity: [],
356
+ Memories: [],
357
+ Locks: [],
358
+ 'Generated Artifacts': [],
359
+ promptCHMOD: [],
360
+ 'Queue Authorship': [],
361
+ };
362
+ const changes = [];
363
+ const config = getConfig(root);
364
+
365
+ if (!json) {
366
+ console.log(`Nexus doctor${fix ? ' --fix' : ''}`);
367
+ console.log(`Repo: ${root}\n`);
368
+ }
369
+
370
+ const nexusProtocolFiles = ['_NEXUS_CONSTITUTION.md', '_NEXUS_QUEUE.md', '_NEXUS_STANDUP.md'];
371
+ const legacyCheckFiles = [
372
+ ...nexusProtocolFiles,
373
+ '.agy/AGENTS.md',
374
+ '.codex/AGENTS.md',
375
+ '.claude/CLAUDE.md',
376
+ '.gemini/GEMINI.md',
377
+ ];
378
+
379
+ for (const file of nexusProtocolFiles) {
380
+ if (!existsSync(join(root, file))) {
381
+ sections['Nexus Files'].push({
382
+ issue: `Missing ${file}`,
383
+ fix: 'Run `nexus init` or restore the Nexus protocol files.',
384
+ });
385
+ }
386
+ }
387
+
388
+ const standupPath = join(root, '_NEXUS_STANDUP.md');
389
+ if (existsSync(standupPath)) {
390
+ const existing = readFileSync(standupPath, 'utf-8');
391
+ const next = repairStandupGuidance(existing);
392
+ if (next !== existing) {
393
+ if (fix) {
394
+ writeFileSync(standupPath, next, 'utf-8');
395
+ changes.push('updated _NEXUS_STANDUP.md date guidance');
396
+ } else {
397
+ sections['Nexus Files'].push({
398
+ issue: '_NEXUS_STANDUP.md is missing standard dated AM/PM message guidance',
399
+ fix: 'Run `nexus doctor --fix`.',
400
+ });
401
+ }
402
+ }
403
+ }
404
+
405
+ for (const issue of scanPackageSecurity(root)) {
406
+ sections.Security.push(issue);
407
+ }
408
+
409
+ for (const issue of scanPackagePrivacy(root)) {
410
+ sections['Package Privacy'].push(issue);
411
+ }
412
+
413
+ for (const issue of scanGitPrivacy(root)) {
414
+ sections['Git Privacy'].push(issue);
415
+ }
416
+
417
+ for (const issue of scanGeneratedArtifacts(root)) {
418
+ sections['Generated Artifacts'].push(issue);
419
+ }
420
+
421
+ if (!ensureFile(join(root, 'DECISIONS.md'), LOCAL_DECISIONS_TEMPLATE, fix, changes)) {
422
+ sections['Nexus Files'].push({
423
+ issue: 'Missing local DECISIONS.md',
424
+ fix: 'Run `nexus doctor --fix`.',
425
+ });
426
+ }
427
+
428
+ if (!ensureGitignoreLines(root, LOCAL_GITIGNORE_LINES, fix, changes)) {
429
+ sections['Git Privacy'].push({
430
+ issue: '.gitignore is missing Nexus local state entries',
431
+ fix: 'Run `nexus doctor --fix`.',
432
+ });
433
+ }
434
+
435
+ for (const agent of AGENT_SCOPE_LIST) {
436
+ const memoryDir = join(root, agent.memoryDir);
437
+ const monthDir = join(memoryDir, currentMemoryMonthFolder());
438
+ const continuityPath = join(root, agent.continuity);
439
+ const memoryIndexPath = join(root, agent.memoryIndex);
440
+ const entrypointPath = join(root, agent.entrypoint);
441
+
442
+ if (!ensureDir(join(root, agent.entrypoint.split('/')[0]), fix, changes)) {
443
+ sections['Agent Instructions'].push({
444
+ issue: `Missing ${agent.entrypoint.split('/')[0]}/`,
445
+ fix: 'Run `nexus doctor --fix`.',
446
+ });
447
+ }
448
+
449
+ if (!ensureDir(memoryDir, fix, changes)) {
450
+ sections.Memories.push({
451
+ issue: `Missing ${agent.memoryDir}/`,
452
+ fix: 'Run `nexus doctor --fix`.',
453
+ });
454
+ }
455
+
456
+ if (!ensureDir(monthDir, fix, changes)) {
457
+ sections.Memories.push({
458
+ issue: `Missing ${agent.memoryDir}/${currentMemoryMonthFolder()}/`,
459
+ fix: 'Run `nexus doctor --fix`.',
460
+ });
461
+ }
462
+
463
+ if (!ensureFile(continuityPath, CONTINUITY_TEMPLATE, fix, changes)) {
464
+ sections.Continuity.push({
465
+ issue: `Missing ${agent.continuity}`,
466
+ fix: 'Run `nexus doctor --fix`.',
467
+ });
468
+ }
469
+
470
+ if (!ensureFile(memoryIndexPath, MEMORY_INDEX_TEMPLATE, fix, changes)) {
471
+ sections.Memories.push({
472
+ issue: `Missing ${agent.memoryIndex}`,
473
+ fix: 'Run `nexus doctor --fix`.',
474
+ });
475
+ }
476
+
477
+ if (!existsSync(entrypointPath)) {
478
+ if (fix) {
479
+ writeFileSync(entrypointPath, fullEntrypoint(agent), 'utf-8');
480
+ changes.push(`created ${agent.entrypoint}`);
481
+ } else {
482
+ sections['Agent Instructions'].push({
483
+ issue: `Missing ${agent.entrypoint}`,
484
+ fix: 'Run `nexus doctor --fix`.',
485
+ });
486
+ }
487
+ continue;
488
+ }
489
+
490
+ const existing = readFileSync(entrypointPath, 'utf-8');
491
+ const hasProtocol = existing.includes(START_MARKER) && existing.includes(END_MARKER);
492
+ const hasMemoryFlow = existing.includes('YYYY-Month/YYYY-MM-DD-HHMM-topic.md');
493
+ const hasContinuity = existing.includes(agent.continuity);
494
+ const hasSupplyChainSafety = existing.includes('third-party packages that have existed for less than 14 days');
495
+ const hasUnmanagedDuplicate = hasProtocol && hasUnmanagedProtocolBlock(existing);
496
+ const hasCurrentProtocol = hasCurrentManagedProtocolBlock(existing, protocolBlock(agent));
497
+
498
+ if (!hasProtocol || !hasMemoryFlow || !hasContinuity || !hasSupplyChainSafety || hasUnmanagedDuplicate || !hasCurrentProtocol) {
499
+ if (fix) {
500
+ const next = upsertProtocolBlock(existing, protocolBlock(agent));
501
+ writeFileSync(entrypointPath, next, 'utf-8');
502
+ changes.push(`updated ${agent.entrypoint}`);
503
+ } else {
504
+ sections['Agent Instructions'].push({
505
+ issue: `${agent.entrypoint} needs Nexus protocol block update`,
506
+ fix: 'Run `nexus doctor --fix`.',
507
+ });
508
+ }
509
+ }
510
+ }
511
+
512
+ const locks = listLocks();
513
+ const staleLocks = locks.filter((lock) => lock.age !== null && lock.age >= config.staleThreshold);
514
+ const freshLocks = locks.filter((lock) => lock.age === null || lock.age < config.staleThreshold);
515
+
516
+ if (staleLocks.length) {
517
+ for (const lock of staleLocks) {
518
+ sections.Locks.push({
519
+ issue: `Stale lock on ${lock.target} (${lock.age}s old)`,
520
+ fix: 'Run `nexus clean --stale`.',
521
+ });
522
+ }
523
+ }
524
+
525
+ if (freshLocks.length) {
526
+ for (const lock of freshLocks) {
527
+ const age = lock.age === null ? 'unknown age' : `${lock.age}s old`;
528
+ sections.Locks.push({
529
+ issue: `Active lock on ${lock.target} (${age})`,
530
+ fix: 'No action if the agent is still working. Use `nexus status` to inspect.',
531
+ ok: true,
532
+ });
533
+ if (!lock.model) {
534
+ sections.Locks.push({
535
+ issue: `Active lock on ${lock.target} has no --model metadata`,
536
+ fix: 'Use `nexus claim ... --model <name>` for future claims; only the human operator can declare the real model.',
537
+ ok: true,
538
+ });
539
+ }
540
+ if (!lock.verified) {
541
+ sections.Locks.push({
542
+ issue: `Unverified claim on ${lock.target} by ${lock.agent} (trust: ${lock.trustSource}) — no CLAUDECODE or NEXUS_AGENT env detected at claim time`,
543
+ fix: 'If this is a local/unverified model, set NEXUS_AGENT=@handle before claiming. If unexpected, inspect the lock.',
544
+ });
545
+ }
546
+ }
547
+ }
548
+
549
+ // Orphan presence — agent checked in but crashed without checking out
550
+ const presenceDir = join(root, '.nexus', 'presence');
551
+ if (existsSync(presenceDir)) {
552
+ const activeLockAgents = new Set(freshLocks.map(l => l.agent.replace(/^@/, '').toLowerCase()));
553
+ const now = Math.floor(Date.now() / 1000);
554
+ for (const file of readdirSync(presenceDir)) {
555
+ try {
556
+ const ts = parseInt(readFileSync(join(presenceDir, file), 'utf-8').trim(), 10);
557
+ const age = now - ts;
558
+ if (age >= config.staleThreshold && !activeLockAgents.has(file.toLowerCase())) {
559
+ sections.Locks.push({
560
+ issue: `Orphan presence for @${file} (${age}s old, no active lock) — agent likely crashed without checking out`,
561
+ fix: `Run \`nexus checkout @${file}\` to clear it.`,
562
+ });
563
+ }
564
+ } catch { /* skip unreadable */ }
565
+ }
566
+ }
567
+
568
+ // promptCHMOD hygiene — check matrix exists and covers core protocol files
569
+ const CORE_PROTOCOL_FILES = ['_NEXUS_CONSTITUTION.md', '_NEXUS_QUEUE.md', '_NEXUS_STANDUP.md', '_NEXUS_REPORT.md'];
570
+ if (!existsSync(getChmodPath())) {
571
+ if (fix) {
572
+ writeFileSync(getChmodPath(), DEFAULT_MATRIX, 'utf-8');
573
+ changes.push('created _NEXUS_CHMOD.md');
574
+ sections.promptCHMOD.push({
575
+ issue: 'Permission matrix present and core protocol files covered',
576
+ ok: true,
577
+ });
578
+ } else {
579
+ sections.promptCHMOD.push({
580
+ issue: '_NEXUS_CHMOD.md is missing — prompt injection surface is undeclared',
581
+ fix: 'Run `nexus chmod --init` to create the default permission matrix.',
582
+ });
583
+ }
584
+ } else {
585
+ const perms = loadPermissions();
586
+ const covered = new Set(perms.map(e => e.path));
587
+ for (const file of CORE_PROTOCOL_FILES) {
588
+ if (!covered.has(file)) {
589
+ sections.promptCHMOD.push({
590
+ issue: `${file} has no entry in _NEXUS_CHMOD.md`,
591
+ fix: `Add it: nexus chmod ${file} rw- all (or r-- if agents should not modify it)`,
592
+ });
593
+ }
594
+ }
595
+ if (!sections.promptCHMOD.length) {
596
+ sections.promptCHMOD.push({
597
+ issue: 'Permission matrix present and core protocol files covered',
598
+ ok: true,
599
+ });
600
+ }
601
+ }
602
+
603
+ // Queue authorship gate — warn on auto-flow tasks in Ready Queue missing Review: approved
604
+ const queuePath = join(root, '_NEXUS_QUEUE.md');
605
+ if (existsSync(queuePath)) {
606
+ const queueContent = readFileSync(queuePath, 'utf-8');
607
+ const readySection = extractReadyQueueSection(queueContent);
608
+ const unapproved = findUnapprovedAutoFlow(readySection);
609
+ if (unapproved.length) {
610
+ for (const id of unapproved) {
611
+ sections['Queue Authorship'].push({
612
+ issue: `Task "${id}" is auto-flow: yes in Ready Queue but missing Review: approved — nexus next will skip it`,
613
+ fix: 'Add "- Review: approved" and "- Approved by: human" to the task, or move it to ## Proposed Queue.',
614
+ });
615
+ }
616
+ } else {
617
+ sections['Queue Authorship'].push({
618
+ issue: 'All auto-flow tasks in Ready Queue have Review: approved',
619
+ fix: 'No action needed.',
620
+ ok: true,
621
+ });
622
+ }
623
+ }
624
+
625
+ for (const relativePath of legacyCheckFiles) {
626
+ const path = join(root, relativePath);
627
+ if (!existsSync(path)) continue;
628
+
629
+ const existing = readFileSync(path, 'utf-8');
630
+ const next = replaceLegacyHelperCommands(existing);
631
+ if (next === existing) continue;
632
+
633
+ if (fix) {
634
+ writeFileSync(path, next, 'utf-8');
635
+ changes.push(`updated legacy Nexus helper commands in ${relativePath}`);
636
+ } else {
637
+ sections['Legacy Helpers'].push({
638
+ issue: `${relativePath} references legacy _nexus_*.sh helpers`,
639
+ fix: 'Use `nexus claim`, `nexus release`, and `nexus next`; run `nexus doctor --fix` to update checked docs.',
640
+ });
641
+ }
642
+ }
643
+
644
+ if (json) {
645
+ const problemCount = Object.values(sections)
646
+ .flat()
647
+ .filter((entry) => !entry.ok).length;
648
+ console.log(JSON.stringify({
649
+ ok: problemCount === 0,
650
+ repo: root,
651
+ fix,
652
+ sections,
653
+ changes,
654
+ }, null, 2));
655
+ return;
656
+ }
657
+
658
+ if (changes.length) {
659
+ console.log('Applied fixes:');
660
+ for (const change of changes) console.log(` - ${change}`);
661
+ console.log('');
662
+ }
663
+
664
+ let problemCount = 0;
665
+ for (const [title, entries] of Object.entries(sections)) {
666
+ console.log(`[${title}]`);
667
+ if (!entries.length) {
668
+ console.log(' OK');
669
+ console.log('');
670
+ continue;
671
+ }
672
+
673
+ for (const entry of entries) {
674
+ const prefix = entry.ok ? '-' : '!';
675
+ console.log(` ${prefix} ${entry.issue}`);
676
+ if (entry.fix) console.log(` Fix: ${entry.fix}`);
677
+ if (!entry.ok) problemCount++;
678
+ }
679
+ console.log('');
680
+ }
681
+
682
+ if (problemCount) {
683
+ console.log('Some issues need attention. Safe scaffold fixes: `nexus doctor --fix`.');
684
+ return;
685
+ }
686
+
687
+ console.log('All checked Nexus categories are ready.');
688
+ }
689
+
690
+ function extractReadyQueueSection(content) {
691
+ const lines = content.split('\n');
692
+ let inSection = false;
693
+ const result = [];
694
+ for (const line of lines) {
695
+ if (line.startsWith('## ')) { inSection = line.trim() === '## Ready Queue'; continue; }
696
+ if (inSection) result.push(line);
697
+ }
698
+ return result.join('\n');
699
+ }
700
+
701
+ function findUnapprovedAutoFlow(sectionContent) {
702
+ const unapproved = [];
703
+ const lines = sectionContent.split('\n');
704
+ let currentId = '';
705
+ let isAutoFlow = false;
706
+ let hasReview = false;
707
+
708
+ for (const line of lines) {
709
+ const taskMatch = line.match(/^- \[[ ]\] TASK\/.+?:\s*(.+)/);
710
+ if (taskMatch) {
711
+ if (currentId && isAutoFlow && !hasReview) unapproved.push(currentId);
712
+ currentId = '';
713
+ isAutoFlow = false;
714
+ hasReview = false;
715
+ continue;
716
+ }
717
+ if (line.match(/^- \[x\]/)) {
718
+ if (currentId && isAutoFlow && !hasReview) unapproved.push(currentId);
719
+ currentId = '';
720
+ isAutoFlow = false;
721
+ hasReview = false;
722
+ continue;
723
+ }
724
+ if (!line.trim().startsWith('- ')) continue;
725
+ const kv = line.trim().replace(/^-\s*/, '');
726
+ const colonIdx = kv.indexOf(':');
727
+ if (colonIdx === -1) continue;
728
+ const key = kv.slice(0, colonIdx).trim().toLowerCase();
729
+ const val = kv.slice(colonIdx + 1).trim().toLowerCase();
730
+ if (key === 'id') currentId = val;
731
+ if (key === 'auto-flow' && val === 'yes') isAutoFlow = true;
732
+ if (key === 'review' && val === 'approved') hasReview = true;
733
+ }
734
+ if (currentId && isAutoFlow && !hasReview) unapproved.push(currentId);
735
+ return unapproved;
736
+ }
737
+
738
+ function replaceLegacyHelperCommands(content) {
739
+ return content
740
+ .split('\n')
741
+ .map((line) => {
742
+ if (line.includes('-> nexus ')) return line;
743
+ return line
744
+ .replaceAll('./_nexus_claim.sh', 'nexus claim')
745
+ .replaceAll('_nexus_claim.sh', 'nexus claim')
746
+ .replaceAll('./_nexus_release.sh', 'nexus release')
747
+ .replaceAll('_nexus_release.sh', 'nexus release')
748
+ .replaceAll('./_nexus_next.sh', 'nexus next')
749
+ .replaceAll('_nexus_next.sh', 'nexus next');
750
+ })
751
+ .join('\n');
752
+ }
753
+
754
+ const SUSPICIOUS_SCRIPT_PATTERNS = [
755
+ { pattern: /\b(curl|wget)\b/i, label: 'network download command' },
756
+ { pattern: /\b(nc|netcat|ncat|socat)\b/i, label: 'raw network transfer command' },
757
+ { pattern: /\b(scp|rsync)\b/i, label: 'remote file transfer command' },
758
+ { pattern: /\bssh\b/i, label: 'remote shell command' },
759
+ { pattern: /https?:\/\/|webhook|discord\.com\/api|hooks\.slack\.com/i, label: 'external URL or webhook' },
760
+ { pattern: /\b[A-Z0-9_]*(TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY)[A-Z0-9_]*\b/, label: 'secret-looking environment variable' },
761
+ ];
762
+
763
+ const INSTALL_HOOKS = new Set([
764
+ 'preinstall',
765
+ 'install',
766
+ 'postinstall',
767
+ 'prepublish',
768
+ 'prepare',
769
+ ]);
770
+
771
+ function scanPackageSecurity(root) {
772
+ const packagePath = join(root, 'package.json');
773
+ if (!existsSync(packagePath)) return [];
774
+
775
+ let pkg;
776
+ try {
777
+ pkg = JSON.parse(readFileSync(packagePath, 'utf-8'));
778
+ } catch {
779
+ return [{
780
+ issue: 'package.json could not be parsed for security checks',
781
+ fix: 'Fix package.json syntax before running agent installs.',
782
+ }];
783
+ }
784
+
785
+ const issues = [];
786
+ const scripts = pkg.scripts || {};
787
+
788
+ for (const [name, command] of Object.entries(scripts)) {
789
+ if (typeof command !== 'string') continue;
790
+
791
+ if (INSTALL_HOOKS.has(name)) {
792
+ issues.push({
793
+ issue: `package.json script "${name}" runs during install`,
794
+ fix: 'Human-review install hooks before allowing an agent to install dependencies.',
795
+ });
796
+ }
797
+
798
+ for (const { pattern, label } of SUSPICIOUS_SCRIPT_PATTERNS) {
799
+ if (!pattern.test(command)) continue;
800
+ issues.push({
801
+ issue: `package.json script "${name}" contains ${label}: ${command}`,
802
+ fix: 'Human-review this script for exfiltration risk before an agent runs it.',
803
+ });
804
+ break;
805
+ }
806
+ }
807
+
808
+ return issues;
809
+ }
810
+
811
+ const PRIVATE_PACKAGE_PATHS = [
812
+ '.agent-*',
813
+ '.agent-session-logs',
814
+ '.nexus/local',
815
+ '.agy',
816
+ '.antigravitycli',
817
+ '.codex',
818
+ '.claude',
819
+ '.gemini',
820
+ 'agent-overlay.md',
821
+ 'DECISIONS.md',
822
+ 'docs-priv',
823
+ 'SOUL.md',
824
+ 'scratch',
825
+ 'session-logs',
826
+ 'IDENTITY.md',
827
+ 'USER.md',
828
+ ];
829
+
830
+ const PRIVATE_GIT_PATHS = [
831
+ '.agent-*',
832
+ '.agent-session-logs',
833
+ '.agy',
834
+ '.antigravitycli',
835
+ '.codex',
836
+ '.claude',
837
+ '.gemini',
838
+ '.nexus/local',
839
+ 'DECISIONS.md',
840
+ 'docs-priv',
841
+ 'scratch',
842
+ 'session-logs',
843
+ 'USER.md',
844
+ ];
845
+
846
+ function scanPackagePrivacy(root) {
847
+ const packagePath = join(root, 'package.json');
848
+ if (!existsSync(packagePath)) return [];
849
+
850
+ let pkg;
851
+ try {
852
+ pkg = JSON.parse(readFileSync(packagePath, 'utf-8'));
853
+ } catch {
854
+ return [];
855
+ }
856
+
857
+ const files = Array.isArray(pkg.files) ? pkg.files : [];
858
+ const issues = [];
859
+
860
+ for (const entry of files) {
861
+ if (typeof entry !== 'string') continue;
862
+ const normalized = entry.replace(/^\.\//, '').replace(/\/$/, '');
863
+ for (const privatePath of PRIVATE_PACKAGE_PATHS) {
864
+ if (matchesPrivatePath(normalized, privatePath)) {
865
+ issues.push({
866
+ issue: `package.json files includes private/local path: ${entry}`,
867
+ fix: 'Remove private local agent state from package.json files before publishing.',
868
+ });
869
+ break;
870
+ }
871
+ }
872
+ }
873
+
874
+ return issues;
875
+ }
876
+
877
+ function scanGitPrivacy(root) {
878
+ const gitDir = join(root, '.git');
879
+ if (!existsSync(gitDir)) return [];
880
+
881
+ const result = spawnSync('git', ['ls-files', '--', ...PRIVATE_GIT_PATHS], {
882
+ cwd: root,
883
+ encoding: 'utf-8',
884
+ stdio: 'pipe',
885
+ });
886
+ if (result.status !== 0) return [];
887
+
888
+ const tracked = result.stdout.split('\n').filter(Boolean);
889
+ return tracked.map((file) => ({
890
+ issue: `Git tracks private/local path: ${file}`,
891
+ fix: 'Untrack it without deleting local files: `git rm --cached -r -- <path>`, then add an ignore rule.',
892
+ }));
893
+ }
894
+
895
+ function scanGeneratedArtifacts(root) {
896
+ const gitDir = join(root, '.git');
897
+ if (!existsSync(gitDir)) return [];
898
+
899
+ const result = spawnSync('git', ['status', '--porcelain', '--untracked-files=all'], {
900
+ cwd: root,
901
+ encoding: 'utf-8',
902
+ stdio: 'pipe',
903
+ });
904
+ if (result.status !== 0) return [];
905
+
906
+ const seen = new Set();
907
+ const artifacts = [];
908
+ for (const line of result.stdout.split('\n')) {
909
+ if (!line.startsWith('?? ')) continue;
910
+ const file = parseGitStatusPath(line.slice(3).trim());
911
+ const ownerPath = generatedArtifactOwnerPath(file);
912
+ if (!ownerPath || seen.has(ownerPath)) continue;
913
+ seen.add(ownerPath);
914
+ artifacts.push({
915
+ issue: `Untracked generated-looking artifact needs owner decision: ${ownerPath}`,
916
+ fix: 'Decide keep/delete/ignore, or claim and release it intentionally. Nexus will not delete it automatically.',
917
+ });
918
+ }
919
+ return artifacts;
920
+ }
921
+
922
+ function generatedArtifactOwnerPath(file) {
923
+ const normalized = file.replace(/\\/g, '/');
924
+ if (/(^|\/)(dist|build|coverage|tmp|temp|exports?|reports?|ledgers?|screenshots?)(\/|$)/i.test(normalized)) {
925
+ return firstPathSegment(normalized);
926
+ }
927
+ if (/(^|\/)[^/]*\bcopy\b[^/]*(\/|$)/i.test(normalized)) {
928
+ return firstPathSegment(normalized);
929
+ }
930
+ if (/\.(png|jpe?g|gif|webp|pdf|log|tmp)$/i.test(normalized)) {
931
+ return normalized;
932
+ }
933
+ return '';
934
+ }
935
+
936
+ function parseGitStatusPath(file) {
937
+ if (!file.startsWith('"') || !file.endsWith('"')) return file;
938
+
939
+ try {
940
+ return JSON.parse(file);
941
+ } catch {
942
+ return file.slice(1, -1);
943
+ }
944
+ }
945
+
946
+ function firstPathSegment(file) {
947
+ return file.split('/')[0];
948
+ }
949
+
950
+ function matchesPrivatePath(normalized, privatePath) {
951
+ if (privatePath.endsWith('*')) {
952
+ const prefix = privatePath.slice(0, -1);
953
+ return normalized.startsWith(prefix);
954
+ }
955
+ return normalized === privatePath ||
956
+ normalized.startsWith(`${privatePath}/`) ||
957
+ normalized.endsWith(`/${privatePath}`);
958
+ }