proteum 2.5.1 → 2.5.3

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 (36) hide show
  1. package/AGENTS.md +3 -3
  2. package/README.md +12 -9
  3. package/agents/project/AGENTS.md +10 -8
  4. package/agents/project/CODING_STYLE.md +1 -1
  5. package/agents/project/diagnostics.md +2 -2
  6. package/agents/project/root/AGENTS.md +10 -8
  7. package/agents/project/tests/AGENTS.md +1 -1
  8. package/cli/commands/configure.ts +5 -5
  9. package/cli/commands/dev.ts +3 -3
  10. package/cli/commands/verify.ts +117 -4
  11. package/cli/compiler/artifacts/controllerHelper.ts +66 -0
  12. package/cli/compiler/artifacts/controllers.ts +3 -0
  13. package/cli/compiler/artifacts/services.ts +14 -8
  14. package/cli/compiler/common/generatedRouteModules.ts +270 -53
  15. package/cli/presentation/commands.ts +14 -3
  16. package/cli/presentation/help.ts +1 -1
  17. package/cli/runtime/commands.ts +6 -0
  18. package/cli/scaffold/templates.ts +11 -3
  19. package/cli/utils/agents.ts +271 -2
  20. package/cli/verification/changed.ts +460 -0
  21. package/client/app/index.ts +1 -1
  22. package/client/services/router/index.tsx +1 -1
  23. package/common/applicationConfig.ts +177 -0
  24. package/common/applicationConfigLoader.ts +33 -1
  25. package/common/dev/contractsDoctor.ts +16 -0
  26. package/config.ts +5 -1
  27. package/docs/migration-2.5.md +54 -11
  28. package/eslint.js +42 -8
  29. package/package.json +1 -1
  30. package/tests/agents-utils.test.cjs +72 -0
  31. package/tests/cli-mcp-command.test.cjs +14 -0
  32. package/tests/contracts-doctor.test.cjs +98 -0
  33. package/tests/definition-contracts.test.cjs +129 -0
  34. package/tests/eslint-rules.test.cjs +100 -0
  35. package/tests/scaffold-templates.test.cjs +27 -2
  36. package/tests/verify-changed.test.cjs +200 -0
@@ -60,6 +60,9 @@ const managedInstructionSectionHeader = '# Proteum Instructions';
60
60
  const managedInstructionSectionStart = '<!-- proteum-instructions:start -->';
61
61
  const managedInstructionSectionEnd = '<!-- proteum-instructions:end -->';
62
62
  const managedInstructionSectionIntro = 'This section is managed by `proteum configure agents`.';
63
+ const agentInstructionFilename = 'AGENTS.md';
64
+ const claudeInstructionFilename = 'CLAUDE.md';
65
+ const claudeInstructionPointerContent = `@${agentInstructionFilename}`;
63
66
 
64
67
  const sharedRootDocumentInstructionDefinitions: TAgentInstructionDefinition[] = [
65
68
  { projectPath: 'DOCUMENTATION.md', content: 'source' },
@@ -243,6 +246,45 @@ function getRootAgentInstructionDefinitions() {
243
246
  return monorepoRootAgentInstructionDefinitions.map((instructionDefinition) => ({ ...instructionDefinition }));
244
247
  }
245
248
 
249
+ function createEmptyInstructionResult(): TEnsureInstructionFilesResult {
250
+ return {
251
+ blocked: [],
252
+ created: [],
253
+ overwritten: [],
254
+ removed: [],
255
+ skipped: [],
256
+ updated: [],
257
+ };
258
+ }
259
+
260
+ function mergeEnsureInstructionResults(result: TEnsureInstructionFilesResult, next: TEnsureInstructionFilesResult) {
261
+ result.created.push(...next.created);
262
+ result.overwritten.push(...next.overwritten);
263
+ result.removed.push(...next.removed);
264
+ result.updated.push(...next.updated);
265
+ result.skipped.push(...next.skipped);
266
+ result.blocked.push(...next.blocked);
267
+ }
268
+
269
+ function getManagedInstructionProjectPaths(instructionDefinitions: TAgentInstructionDefinition[]) {
270
+ const projectPaths: string[] = [];
271
+
272
+ for (const instructionDefinition of instructionDefinitions) {
273
+ projectPaths.push(instructionDefinition.projectPath);
274
+
275
+ const claudeProjectPath = getClaudeCompanionProjectPath(instructionDefinition.projectPath);
276
+ if (claudeProjectPath) projectPaths.push(claudeProjectPath);
277
+ }
278
+
279
+ return projectPaths;
280
+ }
281
+
282
+ function getClaudeCompanionProjectPath(projectPath: string) {
283
+ if (path.basename(projectPath).toLowerCase() !== agentInstructionFilename.toLowerCase()) return undefined;
284
+
285
+ return path.join(path.dirname(projectPath), claudeInstructionFilename);
286
+ }
287
+
246
288
  function removeInstructionGitignoreEntries({
247
289
  rootDir,
248
290
  instructionDefinitions,
@@ -254,7 +296,7 @@ function removeInstructionGitignoreEntries({
254
296
  if (!pathEntryExists(gitignoreFilepath)) return false;
255
297
 
256
298
  const managedEntries = new Set(
257
- instructionDefinitions.map((instructionDefinition) => normalizeGitignoreEntry(instructionDefinition.projectPath)),
299
+ getManagedInstructionProjectPaths(instructionDefinitions).map((projectPath) => normalizeGitignoreEntry(projectPath)),
258
300
  );
259
301
  const lines = fs.readFileSync(gitignoreFilepath, 'utf8').split(/\r?\n/);
260
302
  const filteredLines: string[] = [];
@@ -341,12 +383,28 @@ function ensureInstructionFiles(
341
383
  : upsertManagedInstructionSection(existingState.content, instructionContent);
342
384
  if (nextContent === existingState.content) {
343
385
  result.skipped.push(relativeProjectPath);
386
+ ensureClaudeCompanionIfNeeded({
387
+ dryRun,
388
+ instructionDefinition,
389
+ logPrefix,
390
+ overwriteBlockedPaths,
391
+ result,
392
+ rootDir,
393
+ });
344
394
  continue;
345
395
  }
346
396
 
347
397
  if (!dryRun) fs.writeFileSync(projectFilepath, nextContent);
348
398
  result.updated.push(relativeProjectPath);
349
399
  logVerbose(`${logPrefix} Updated ${relativeProjectPath}`);
400
+ ensureClaudeCompanionIfNeeded({
401
+ dryRun,
402
+ instructionDefinition,
403
+ logPrefix,
404
+ overwriteBlockedPaths,
405
+ result,
406
+ rootDir,
407
+ });
350
408
  continue;
351
409
  }
352
410
 
@@ -357,6 +415,14 @@ function ensureInstructionFiles(
357
415
  }
358
416
  result.updated.push(relativeProjectPath);
359
417
  logVerbose(`${logPrefix} Updated ${relativeProjectPath}`);
418
+ ensureClaudeCompanionIfNeeded({
419
+ dryRun,
420
+ instructionDefinition,
421
+ logPrefix,
422
+ overwriteBlockedPaths,
423
+ result,
424
+ rootDir,
425
+ });
360
426
  continue;
361
427
  }
362
428
 
@@ -373,12 +439,28 @@ function ensureInstructionFiles(
373
439
  }
374
440
  result.overwritten.push(relativeProjectPath);
375
441
  logVerbose(`${logPrefix} Replaced ${relativeProjectPath}`);
442
+ ensureClaudeCompanionIfNeeded({
443
+ dryRun,
444
+ instructionDefinition,
445
+ logPrefix,
446
+ overwriteBlockedPaths,
447
+ result,
448
+ rootDir,
449
+ });
376
450
  continue;
377
451
  }
378
452
 
379
453
  if (!dryRun) fs.writeFileSync(projectFilepath, instructionContent);
380
454
  result.created.push(relativeProjectPath);
381
455
  logVerbose(`${logPrefix} Created ${relativeProjectPath}`);
456
+ ensureClaudeCompanionIfNeeded({
457
+ dryRun,
458
+ instructionDefinition,
459
+ logPrefix,
460
+ overwriteBlockedPaths,
461
+ result,
462
+ rootDir,
463
+ });
382
464
  }
383
465
 
384
466
  return result;
@@ -422,6 +504,13 @@ function removeManagedInstructionFiles(
422
504
  if (!dryRun) fs.removeSync(projectFilepath);
423
505
  result.removed.push(relativeProjectPath);
424
506
  logVerbose(`${logPrefix} Removed retired app-root ${relativeProjectPath}`);
507
+ removeClaudeCompanionIfNeeded({
508
+ dryRun,
509
+ instructionDefinition,
510
+ logPrefix,
511
+ result,
512
+ rootDir,
513
+ });
425
514
  continue;
426
515
  }
427
516
 
@@ -437,6 +526,13 @@ function removeManagedInstructionFiles(
437
526
  if (!dryRun) fs.removeSync(projectFilepath);
438
527
  result.removed.push(relativeProjectPath);
439
528
  logVerbose(`${logPrefix} Removed retired app-root ${relativeProjectPath}`);
529
+ removeClaudeCompanionIfNeeded({
530
+ dryRun,
531
+ instructionDefinition,
532
+ logPrefix,
533
+ result,
534
+ rootDir,
535
+ });
440
536
  continue;
441
537
  }
442
538
 
@@ -452,6 +548,179 @@ function removeManagedInstructionFiles(
452
548
  return result;
453
549
  }
454
550
 
551
+ function ensureClaudeCompanionIfNeeded({
552
+ dryRun,
553
+ instructionDefinition,
554
+ logPrefix,
555
+ overwriteBlockedPaths,
556
+ result,
557
+ rootDir,
558
+ }: {
559
+ dryRun: boolean;
560
+ instructionDefinition: TAgentInstructionDefinition;
561
+ logPrefix: string;
562
+ overwriteBlockedPaths: Set<string>;
563
+ result: TEnsureInstructionFilesResult;
564
+ rootDir: string;
565
+ }) {
566
+ const claudeProjectPath = getClaudeCompanionProjectPath(instructionDefinition.projectPath);
567
+ if (!claudeProjectPath) return;
568
+
569
+ mergeEnsureInstructionResults(
570
+ result,
571
+ ensureClaudeInstructionSymlink({
572
+ claudeProjectPath,
573
+ dryRun,
574
+ logPrefix,
575
+ overwriteBlockedPaths,
576
+ rootDir,
577
+ }),
578
+ );
579
+ }
580
+
581
+ function ensureClaudeInstructionSymlink({
582
+ claudeProjectPath,
583
+ dryRun,
584
+ logPrefix,
585
+ overwriteBlockedPaths,
586
+ rootDir,
587
+ }: {
588
+ claudeProjectPath: string;
589
+ dryRun: boolean;
590
+ logPrefix: string;
591
+ overwriteBlockedPaths: Set<string>;
592
+ rootDir: string;
593
+ }): TEnsureInstructionFilesResult {
594
+ const result = createEmptyInstructionResult();
595
+ const claudeFilepath = path.join(rootDir, claudeProjectPath);
596
+ const claudeParentDir = path.dirname(claudeFilepath);
597
+ const relativeClaudePath = path.relative(rootDir, claudeFilepath) || '.';
598
+
599
+ if (!fs.existsSync(claudeParentDir)) {
600
+ result.skipped.push(relativeClaudePath);
601
+ return result;
602
+ }
603
+
604
+ const existingState = inspectExistingClaudePath(claudeFilepath);
605
+
606
+ if (existingState.kind === 'correct') {
607
+ result.skipped.push(relativeClaudePath);
608
+ return result;
609
+ }
610
+
611
+ if (existingState.kind === 'missing') {
612
+ if (!dryRun) fs.symlinkSync(agentInstructionFilename, claudeFilepath);
613
+ result.created.push(relativeClaudePath);
614
+ logVerbose(`${logPrefix} Created ${relativeClaudePath}`);
615
+ return result;
616
+ }
617
+
618
+ if (existingState.kind === 'managed-different') {
619
+ if (!dryRun) {
620
+ fs.removeSync(claudeFilepath);
621
+ fs.symlinkSync(agentInstructionFilename, claudeFilepath);
622
+ }
623
+ result.updated.push(relativeClaudePath);
624
+ logVerbose(`${logPrefix} Updated ${relativeClaudePath}`);
625
+ return result;
626
+ }
627
+
628
+ const normalizedClaudeFilepath = normalizeAbsolutePath(claudeFilepath);
629
+ if (!overwriteBlockedPaths.has(normalizedClaudeFilepath)) {
630
+ result.blocked.push(relativeClaudePath);
631
+ return result;
632
+ }
633
+
634
+ if (!dryRun) {
635
+ fs.removeSync(claudeFilepath);
636
+ fs.symlinkSync(agentInstructionFilename, claudeFilepath);
637
+ }
638
+ result.overwritten.push(relativeClaudePath);
639
+ logVerbose(`${logPrefix} Replaced ${relativeClaudePath}`);
640
+
641
+ return result;
642
+ }
643
+
644
+ function removeClaudeCompanionIfNeeded({
645
+ dryRun,
646
+ instructionDefinition,
647
+ logPrefix,
648
+ result,
649
+ rootDir,
650
+ }: {
651
+ dryRun: boolean;
652
+ instructionDefinition: TAgentInstructionDefinition;
653
+ logPrefix: string;
654
+ result: TEnsureInstructionFilesResult;
655
+ rootDir: string;
656
+ }) {
657
+ const claudeProjectPath = getClaudeCompanionProjectPath(instructionDefinition.projectPath);
658
+ if (!claudeProjectPath) return;
659
+
660
+ mergeEnsureInstructionResults(
661
+ result,
662
+ removeClaudeInstructionSymlink({
663
+ claudeProjectPath,
664
+ dryRun,
665
+ logPrefix,
666
+ rootDir,
667
+ }),
668
+ );
669
+ }
670
+
671
+ function removeClaudeInstructionSymlink({
672
+ claudeProjectPath,
673
+ dryRun,
674
+ logPrefix,
675
+ rootDir,
676
+ }: {
677
+ claudeProjectPath: string;
678
+ dryRun: boolean;
679
+ logPrefix: string;
680
+ rootDir: string;
681
+ }): TEnsureInstructionFilesResult {
682
+ const result = createEmptyInstructionResult();
683
+ const claudeFilepath = path.join(rootDir, claudeProjectPath);
684
+ const relativeClaudePath = path.relative(rootDir, claudeFilepath) || '.';
685
+
686
+ if (!pathEntryExists(claudeFilepath)) return result;
687
+
688
+ const existingState = inspectExistingClaudePath(claudeFilepath);
689
+ if (existingState.kind === 'correct' || existingState.kind === 'managed-different') {
690
+ if (!dryRun) fs.removeSync(claudeFilepath);
691
+ result.removed.push(relativeClaudePath);
692
+ logVerbose(`${logPrefix} Removed retired app-root ${relativeClaudePath}`);
693
+ return result;
694
+ }
695
+
696
+ if (existingState.kind === 'blocked') result.skipped.push(relativeClaudePath);
697
+
698
+ return result;
699
+ }
700
+
701
+ function inspectExistingClaudePath(claudeFilepath: string) {
702
+ if (!pathEntryExists(claudeFilepath)) return { kind: 'missing' as const };
703
+
704
+ const stats = fs.lstatSync(claudeFilepath);
705
+ const claudeParentDir = path.dirname(claudeFilepath);
706
+ const agentFilepath = path.join(claudeParentDir, agentInstructionFilename);
707
+
708
+ if (stats.isSymbolicLink()) {
709
+ const rawTarget = fs.readlinkSync(claudeFilepath);
710
+ const resolvedTarget = path.resolve(claudeParentDir, rawTarget);
711
+ if (normalizeAbsolutePath(resolvedTarget) !== normalizeAbsolutePath(agentFilepath)) return { kind: 'blocked' as const };
712
+
713
+ return rawTarget === agentInstructionFilename ? { kind: 'correct' as const } : { kind: 'managed-different' as const };
714
+ }
715
+
716
+ if (!stats.isFile()) return { kind: 'blocked' as const };
717
+
718
+ const content = fs.readFileSync(claudeFilepath, 'utf8');
719
+ if (content.trim() === claudeInstructionPointerContent) return { kind: 'managed-different' as const };
720
+
721
+ return { kind: 'blocked' as const };
722
+ }
723
+
455
724
  function inspectExistingPath({
456
725
  managedSourceRoot,
457
726
  projectFilepath,
@@ -643,7 +912,7 @@ function renderEmbeddedProjectInstructions({ appRoot, coreRoot, includeMonorepoR
643
912
  '- App roots default-export `defineApplication({ services, router, models, commands })`; `server/index.ts` is the canonical type root for the project app, services, router plugins, request context, and models.',
644
913
  '- Client page files default-export `definePageRoute({ path, options, data, render })` or `defineErrorRoute({ code, options, render })`; route paths come from the definition object, not from `Router.page(...)` or the file path.',
645
914
  '- Manual HTTP route files default-export `defineServerRoute({ method, path, options, handler })` or `defineServerRoutes(...)`; use `expressHandler(...)` only when raw Express `req`, `res`, or `next` is required.',
646
- '- Controllers default-export `defineController({ path, actions })`; actions use `defineAction({ input, handler })`, and parsed input is read from the handler context.',
915
+ '- Controllers default-export `defineController({ path, actions })` from `@generated/server/controller`; actions use `defineAction({ input, handler })`, and parsed input is read from the app-typed handler context.',
647
916
  '- Never import `@app` in page, route, or controller files. Never call top-level `Router.page(...)`, `Router.error(...)`, `Router.get(...)`, `Router.post(...)`, `Router.put(...)`, `Router.patch(...)`, `Router.delete(...)`, or `Router.express(...)` in app source.',
648
917
  '- Runtime app, service, request, response, router, auth, and custom router-plugin access belongs only in typed callback parameters such as `data`, `render`, route `handler`, controller action `handler`, `defineServerRoutes((app) => ...)`, or typed service `this.app`/`this.services`.',
649
918
  '',