@productbrain/cli 0.1.0-beta.15 → 0.1.0-beta.20

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 (91) hide show
  1. package/README.md +61 -0
  2. package/dist/__tests__/capture.test.js +49 -0
  3. package/dist/__tests__/capture.test.js.map +1 -1
  4. package/dist/__tests__/handshake.test.js +18 -1
  5. package/dist/__tests__/handshake.test.js.map +1 -1
  6. package/dist/__tests__/orient.test.d.ts +2 -0
  7. package/dist/__tests__/orient.test.d.ts.map +1 -0
  8. package/dist/__tests__/orient.test.js +143 -0
  9. package/dist/__tests__/orient.test.js.map +1 -0
  10. package/dist/__tests__/promote.test.js +39 -5
  11. package/dist/__tests__/promote.test.js.map +1 -1
  12. package/dist/__tests__/repo-detect.test.js +97 -1
  13. package/dist/__tests__/repo-detect.test.js.map +1 -1
  14. package/dist/__tests__/update.test.js +29 -4
  15. package/dist/__tests__/update.test.js.map +1 -1
  16. package/dist/commands/capture.d.ts.map +1 -1
  17. package/dist/commands/capture.js +62 -5
  18. package/dist/commands/capture.js.map +1 -1
  19. package/dist/commands/codex-prep.d.ts +12 -0
  20. package/dist/commands/codex-prep.d.ts.map +1 -0
  21. package/dist/commands/codex-prep.js +118 -0
  22. package/dist/commands/codex-prep.js.map +1 -0
  23. package/dist/commands/handshake.d.ts +6 -0
  24. package/dist/commands/handshake.d.ts.map +1 -1
  25. package/dist/commands/handshake.js +154 -11
  26. package/dist/commands/handshake.js.map +1 -1
  27. package/dist/commands/orient.d.ts +100 -1
  28. package/dist/commands/orient.d.ts.map +1 -1
  29. package/dist/commands/orient.js +3 -1
  30. package/dist/commands/orient.js.map +1 -1
  31. package/dist/commands/promote.d.ts.map +1 -1
  32. package/dist/commands/promote.js +28 -1
  33. package/dist/commands/promote.js.map +1 -1
  34. package/dist/commands/update.d.ts +1 -0
  35. package/dist/commands/update.d.ts.map +1 -1
  36. package/dist/commands/update.js +23 -2
  37. package/dist/commands/update.js.map +1 -1
  38. package/dist/formatters/capture.d.ts +3 -3
  39. package/dist/formatters/capture.d.ts.map +1 -1
  40. package/dist/formatters/capture.js +2 -1
  41. package/dist/formatters/capture.js.map +1 -1
  42. package/dist/formatters/entry.d.ts +0 -4
  43. package/dist/formatters/entry.d.ts.map +1 -1
  44. package/dist/formatters/entry.js +16 -13
  45. package/dist/formatters/entry.js.map +1 -1
  46. package/dist/formatters/handshake.d.ts +2 -0
  47. package/dist/formatters/handshake.d.ts.map +1 -1
  48. package/dist/formatters/handshake.js +10 -1
  49. package/dist/formatters/handshake.js.map +1 -1
  50. package/dist/formatters/orient.d.ts +99 -1
  51. package/dist/formatters/orient.d.ts.map +1 -1
  52. package/dist/formatters/orient.js +121 -17
  53. package/dist/formatters/orient.js.map +1 -1
  54. package/dist/formatters/search.d.ts +0 -4
  55. package/dist/formatters/search.d.ts.map +1 -1
  56. package/dist/formatters/search.js +4 -1
  57. package/dist/formatters/search.js.map +1 -1
  58. package/dist/formatters/update.d.ts.map +1 -1
  59. package/dist/formatters/update.js +2 -0
  60. package/dist/formatters/update.js.map +1 -1
  61. package/dist/generators/adapters.d.ts +1 -0
  62. package/dist/generators/adapters.d.ts.map +1 -1
  63. package/dist/generators/adapters.js +48 -0
  64. package/dist/generators/adapters.js.map +1 -1
  65. package/dist/generators/adapters.test.d.ts +2 -0
  66. package/dist/generators/adapters.test.d.ts.map +1 -0
  67. package/dist/generators/adapters.test.js +27 -0
  68. package/dist/generators/adapters.test.js.map +1 -0
  69. package/dist/generators/portable-knowledge.d.ts +58 -5
  70. package/dist/generators/portable-knowledge.d.ts.map +1 -1
  71. package/dist/generators/portable-knowledge.js +223 -13
  72. package/dist/generators/portable-knowledge.js.map +1 -1
  73. package/dist/generators/portable-knowledge.test.js +529 -1
  74. package/dist/generators/portable-knowledge.test.js.map +1 -1
  75. package/dist/index.js +24 -2
  76. package/dist/index.js.map +1 -1
  77. package/dist/lib/repo-detect.d.ts +19 -0
  78. package/dist/lib/repo-detect.d.ts.map +1 -1
  79. package/dist/lib/repo-detect.js +25 -0
  80. package/dist/lib/repo-detect.js.map +1 -1
  81. package/dist/lib/strip.d.ts +1 -0
  82. package/dist/lib/strip.d.ts.map +1 -1
  83. package/dist/lib/strip.js +15 -0
  84. package/dist/lib/strip.js.map +1 -1
  85. package/package.json +3 -2
  86. package/templates/general/code-integrity.md +11 -0
  87. package/templates/general/getting-started.md +12 -0
  88. package/templates/node-ts/code-integrity.md +13 -0
  89. package/templates/node-ts/testing.md +12 -0
  90. package/templates/python/code-integrity.md +13 -0
  91. package/templates/python/testing.md +12 -0
@@ -39,7 +39,7 @@ vi.mock('fs', () => ({
39
39
  return [...files];
40
40
  }),
41
41
  }));
42
- import { readCanonicalSkills, shouldEmitToTarget, stripTransportSections, filterByLevel, generateCursorSkill, generateClaudeSkillRouter, } from './portable-knowledge.js';
42
+ import { readCanonicalSkills, readCanonicalRules, shouldEmitToTarget, stripTransportSections, filterByLevel, evaluateConditions, generateCursorSkill, generateCursorRule, generateClaudeRule, generateCodexSkill, generateCodexSkillIndex, generateClaudeSkillRouter, } from './portable-knowledge.js';
43
43
  const PB_DIR = '/tmp/pb-test/.productbrain';
44
44
  describe('readCanonicalSkills', () => {
45
45
  beforeEach(() => {
@@ -134,6 +134,7 @@ describe('shouldEmitToTarget', () => {
134
134
  expect(shouldEmitToTarget(skill, 'claude')).toBe(true);
135
135
  expect(shouldEmitToTarget(skill, 'cursor')).toBe(true);
136
136
  expect(shouldEmitToTarget(skill, 'copilot')).toBe(true);
137
+ expect(shouldEmitToTarget(skill, 'codex')).toBe(true);
137
138
  });
138
139
  it('returns true when target is in the list', () => {
139
140
  const skill = {
@@ -292,6 +293,77 @@ Universal content.`,
292
293
  expect(output).not.toContain('<!-- transport:');
293
294
  });
294
295
  });
296
+ describe('generateCodexSkill / generateCodexSkillIndex', () => {
297
+ it('strips non-codex transport sections from generated Codex skill output', () => {
298
+ const skill = {
299
+ name: 'test-skill',
300
+ description: 'Test skill',
301
+ triggers: ['test'],
302
+ body: `# Heading
303
+
304
+ <!-- transport:claude -->
305
+ Claude-only content.
306
+ <!-- /transport -->
307
+
308
+ <!-- transport:codex -->
309
+ Codex-only content.
310
+ <!-- /transport -->
311
+
312
+ Universal content.`,
313
+ sourcePath: '/tmp/.productbrain/skills/test-skill.md',
314
+ };
315
+ const output = generateCodexSkill(skill);
316
+ expect(output).toContain('Codex-only content.');
317
+ expect(output).not.toContain('Claude-only content.');
318
+ expect(output).toContain('Universal content.');
319
+ });
320
+ it('generates a Codex skill index with links to projected skills', () => {
321
+ const skills = [
322
+ {
323
+ name: 'preflight',
324
+ description: 'Ground the task before changing code.',
325
+ triggers: ['preflight', 'system check'],
326
+ body: '# Preflight',
327
+ sourcePath: '/tmp/.productbrain/skills/preflight.md',
328
+ },
329
+ ];
330
+ const output = generateCodexSkillIndex(skills);
331
+ expect(output).toContain('Product Brain Skills for Codex');
332
+ expect(output).toContain('## preflight');
333
+ expect(output).toContain('`preflight`');
334
+ expect(output).toContain('Read: `.codex/skills/preflight.md`');
335
+ });
336
+ it('hides learnings and template files from the Codex skill index', () => {
337
+ const skills = [
338
+ {
339
+ name: 'preflight',
340
+ description: 'Ground the task before changing code.',
341
+ triggers: ['preflight'],
342
+ body: '# Preflight',
343
+ sourcePath: '/tmp/.productbrain/skills/preflight.md',
344
+ },
345
+ {
346
+ name: 'retro-learnings',
347
+ description: 'Internal learnings companion.',
348
+ triggers: [],
349
+ body: '# Retro Learnings',
350
+ sourcePath: '/tmp/.productbrain/skills/retro-learnings.md',
351
+ },
352
+ {
353
+ name: 'LEARNINGS-TEMPLATE',
354
+ description: 'Template',
355
+ triggers: [],
356
+ body: '# Template',
357
+ sourcePath: '/tmp/.productbrain/skills/LEARNINGS-TEMPLATE.md',
358
+ },
359
+ ];
360
+ const output = generateCodexSkillIndex(skills);
361
+ expect(output).toContain('## preflight');
362
+ expect(output).not.toContain('## retro-learnings');
363
+ expect(output).not.toContain('## LEARNINGS-TEMPLATE');
364
+ expect(output).toContain('hidden from this index');
365
+ });
366
+ });
295
367
  describe('generateClaudeSkillRouter (target filtering)', () => {
296
368
  it('includes only skills with matching or no targets', () => {
297
369
  const claudeOnly = {
@@ -355,6 +427,120 @@ describe('generateClaudeSkillRouter (target filtering)', () => {
355
427
  expect(cursorOutput).toContain('universal');
356
428
  });
357
429
  });
430
+ describe('generateCursorRule (scope propagation)', () => {
431
+ const baseRule = {
432
+ name: 'test-rule',
433
+ description: 'A test rule',
434
+ autoApply: true,
435
+ body: '# Rule body content',
436
+ sourcePath: '/tmp/.productbrain/rules/test-rule.md',
437
+ };
438
+ it('emits empty globs and alwaysApply: true when scope is empty string', () => {
439
+ const rule = { ...baseRule, scope: '' };
440
+ const output = generateCursorRule(rule);
441
+ expect(output).toContain('globs: ');
442
+ expect(output).toContain('alwaysApply: true');
443
+ expect(output).toContain('# Rule body content');
444
+ });
445
+ it('emits empty globs and alwaysApply: true when scope is undefined', () => {
446
+ const rule = { ...baseRule };
447
+ const output = generateCursorRule(rule);
448
+ expect(output).toContain('globs: ');
449
+ expect(output).toContain('alwaysApply: true');
450
+ });
451
+ it('emits glob pattern and alwaysApply: false when scope is set', () => {
452
+ const rule = { ...baseRule, scope: 'convex/**/*.ts' };
453
+ const output = generateCursorRule(rule);
454
+ expect(output).toContain('globs: convex/**/*.ts');
455
+ expect(output).toContain('alwaysApply: false');
456
+ });
457
+ it('respects autoApply: false with empty scope', () => {
458
+ const rule = { ...baseRule, autoApply: false, scope: '' };
459
+ const output = generateCursorRule(rule);
460
+ expect(output).toContain('alwaysApply: false');
461
+ });
462
+ it('replaces .productbrain paths with .cursor paths in body', () => {
463
+ const rule = {
464
+ ...baseRule,
465
+ body: 'Read `.productbrain/rules/foo.md` and `.productbrain/skills/bar.md`.',
466
+ };
467
+ const output = generateCursorRule(rule);
468
+ expect(output).toContain('.cursor/rules/foo.mdc');
469
+ expect(output).toContain('.cursor/skills/bar/SKILL.md');
470
+ expect(output).not.toContain('.productbrain/rules/foo.md');
471
+ });
472
+ it('includes source marker comment', () => {
473
+ const output = generateCursorRule(baseRule);
474
+ expect(output).toContain('source: .productbrain/rules/test-rule.md');
475
+ });
476
+ });
477
+ describe('generateClaudeRule (scope propagation)', () => {
478
+ const baseRule = {
479
+ name: 'test-rule',
480
+ description: 'A test rule',
481
+ autoApply: true,
482
+ body: '# Rule body content',
483
+ sourcePath: '/tmp/.productbrain/rules/test-rule.md',
484
+ };
485
+ it('omits paths when scope is empty string', () => {
486
+ const rule = { ...baseRule, scope: '' };
487
+ const output = generateClaudeRule(rule);
488
+ expect(output).not.toContain('paths:');
489
+ expect(output).toContain('description: "A test rule"');
490
+ expect(output).toContain('# Rule body content');
491
+ });
492
+ it('omits paths when scope is undefined', () => {
493
+ const rule = { ...baseRule };
494
+ const output = generateClaudeRule(rule);
495
+ expect(output).not.toContain('paths:');
496
+ });
497
+ it('emits paths array when scope is set', () => {
498
+ const rule = { ...baseRule, scope: 'convex/**/*.ts' };
499
+ const output = generateClaudeRule(rule);
500
+ expect(output).toContain('paths:');
501
+ expect(output).toContain(' - "convex/**/*.ts"');
502
+ });
503
+ it('escapes quotes in description', () => {
504
+ const rule = { ...baseRule, description: 'Rule with "quotes" inside' };
505
+ const output = generateClaudeRule(rule);
506
+ expect(output).toContain('description: "Rule with \\"quotes\\" inside"');
507
+ });
508
+ it('replaces .productbrain paths with .claude paths in body', () => {
509
+ const rule = {
510
+ ...baseRule,
511
+ body: 'Read `.productbrain/rules/foo.md` for details.',
512
+ };
513
+ const output = generateClaudeRule(rule);
514
+ expect(output).toContain('.claude/rules/foo.md');
515
+ expect(output).not.toContain('.productbrain/rules/foo.md');
516
+ });
517
+ it('includes source marker comment', () => {
518
+ const output = generateClaudeRule(baseRule);
519
+ expect(output).toContain('source: .productbrain/rules/test-rule.md');
520
+ });
521
+ });
522
+ describe('generateCodexSkillIndex (empty primary skills)', () => {
523
+ it('shows empty message when all skills are learnings/templates', () => {
524
+ const skills = [
525
+ {
526
+ name: 'retro-learnings',
527
+ description: 'Internal learnings companion.',
528
+ triggers: [],
529
+ body: '# Retro Learnings',
530
+ sourcePath: '/tmp/.productbrain/skills/retro-learnings.md',
531
+ },
532
+ {
533
+ name: 'LEARNINGS-TEMPLATE',
534
+ description: 'Template',
535
+ triggers: [],
536
+ body: '# Template',
537
+ sourcePath: '/tmp/.productbrain/skills/LEARNINGS-TEMPLATE.md',
538
+ },
539
+ ];
540
+ const output = generateCodexSkillIndex(skills);
541
+ expect(output).toContain('No Product Brain skills are currently projected');
542
+ });
543
+ });
358
544
  describe('filterByLevel', () => {
359
545
  const items = [
360
546
  { name: 'core-item', level: 'core' },
@@ -396,4 +582,346 @@ describe('filterByLevel', () => {
396
582
  expect(() => filterByLevel(items, 'unknown')).toThrow('Unknown level "unknown"');
397
583
  });
398
584
  });
585
+ describe('filterByLevel — stage-gating (DEC-443)', () => {
586
+ const items = [
587
+ { name: 'core-item', level: 'core' },
588
+ { name: 'intermediate-item', level: 'intermediate' },
589
+ { name: 'expert-item', level: 'expert' },
590
+ { name: 'no-level-item' },
591
+ ];
592
+ it('stage=blank caps at core even if requestedLevel=expert', () => {
593
+ const result = filterByLevel(items, 'expert', 'blank');
594
+ const names = result.map((i) => i.name);
595
+ expect(names).toContain('core-item');
596
+ expect(names).toContain('no-level-item');
597
+ expect(names).not.toContain('intermediate-item');
598
+ expect(names).not.toContain('expert-item');
599
+ expect(result).toHaveLength(2);
600
+ });
601
+ it('stage=seed caps at intermediate (core + intermediate + no-level)', () => {
602
+ const result = filterByLevel(items, 'expert', 'seed');
603
+ const names = result.map((i) => i.name);
604
+ expect(names).toContain('core-item');
605
+ expect(names).toContain('intermediate-item');
606
+ expect(names).toContain('no-level-item');
607
+ expect(names).not.toContain('expert-item');
608
+ expect(result).toHaveLength(3);
609
+ });
610
+ it('stage=grounded applies no cap — all items returned', () => {
611
+ const result = filterByLevel(items, 'expert', 'grounded');
612
+ expect(result).toHaveLength(4);
613
+ });
614
+ it('stage=connected applies no cap — all items returned', () => {
615
+ const result = filterByLevel(items, 'expert', 'connected');
616
+ expect(result).toHaveLength(4);
617
+ });
618
+ it('stage=undefined (no profile) — no cap, backward compat', () => {
619
+ const result = filterByLevel(items, 'expert', undefined);
620
+ expect(result).toHaveLength(4);
621
+ });
622
+ it('unknown stage — fail-open, no cap applied', () => {
623
+ const result = filterByLevel(items, 'expert', 'unknown-stage');
624
+ expect(result).toHaveLength(4);
625
+ });
626
+ it('no requestedLevel + stage=blank still caps at beginner (core + no-level only)', () => {
627
+ const result = filterByLevel(items, undefined, 'blank');
628
+ const names = result.map((i) => i.name);
629
+ expect(names).toContain('core-item');
630
+ expect(names).toContain('no-level-item');
631
+ expect(names).not.toContain('intermediate-item');
632
+ expect(names).not.toContain('expert-item');
633
+ expect(result).toHaveLength(2);
634
+ });
635
+ });
636
+ describe('evaluateConditions', () => {
637
+ const noProfile = null;
638
+ const profile = {
639
+ stage: 'grounded',
640
+ totalRelations: 10,
641
+ };
642
+ const emptyRepo = { detectedStack: [] };
643
+ const sveltekitRepo = { detectedStack: ['sveltekit', 'typescript'] };
644
+ // 1. No conditions → included
645
+ it('no conditions → included', () => {
646
+ const result = evaluateConditions({}, profile, sveltekitRepo);
647
+ expect(result.included).toBe(true);
648
+ expect(result.reasons).toEqual(['no conditions']);
649
+ });
650
+ // 2. Matching when_stack → included
651
+ it('matching when_stack → included', () => {
652
+ const result = evaluateConditions({ when_stack: 'sveltekit' }, profile, sveltekitRepo);
653
+ expect(result.included).toBe(true);
654
+ expect(result.reasons.some((r) => r.includes('matched'))).toBe(true);
655
+ });
656
+ // 3. Non-matching when_stack → excluded with reason
657
+ it('non-matching when_stack → excluded with reason', () => {
658
+ const result = evaluateConditions({ when_stack: 'nextjs' }, profile, sveltekitRepo);
659
+ expect(result.included).toBe(false);
660
+ expect(result.reasons.some((r) => r.includes('when_stack=nextjs'))).toBe(true);
661
+ });
662
+ // 4. when_stack case-insensitive matching
663
+ it('when_stack is case-insensitive', () => {
664
+ const result = evaluateConditions({ when_stack: 'SvelteKit' }, profile, { detectedStack: ['sveltekit'] });
665
+ expect(result.included).toBe(true);
666
+ });
667
+ // 5. when_minStage ordering: blank < seed < grounded < connected < critical
668
+ it('when_minStage ordering: stage below threshold → excluded', () => {
669
+ const seedProfile = { stage: 'seed', totalRelations: 10 };
670
+ const result = evaluateConditions({ when_minStage: 'grounded' }, seedProfile, emptyRepo);
671
+ expect(result.included).toBe(false);
672
+ expect(result.reasons.some((r) => r.includes('when_minStage=grounded'))).toBe(true);
673
+ });
674
+ // 6. when_minStage with stage below threshold → excluded
675
+ it('when_minStage blank < connected threshold → excluded', () => {
676
+ const blankProfile = { stage: 'blank', totalRelations: 0 };
677
+ const result = evaluateConditions({ when_minStage: 'connected' }, blankProfile, emptyRepo);
678
+ expect(result.included).toBe(false);
679
+ });
680
+ // 7. when_minStage with stage at threshold → included
681
+ it('when_minStage with stage exactly at threshold → included', () => {
682
+ const result = evaluateConditions({ when_minStage: 'grounded' }, profile, emptyRepo);
683
+ expect(result.included).toBe(true);
684
+ expect(result.reasons.some((r) => r.includes('satisfied'))).toBe(true);
685
+ });
686
+ // Also verify stages above threshold pass
687
+ it('when_minStage with stage above threshold → included', () => {
688
+ const criticalProfile = { stage: 'critical', totalRelations: 10 };
689
+ const result = evaluateConditions({ when_minStage: 'grounded' }, criticalProfile, emptyRepo);
690
+ expect(result.included).toBe(true);
691
+ });
692
+ // 8. when_minGovernance with enough relations → included
693
+ it('when_minGovernance with enough relations → included', () => {
694
+ const result = evaluateConditions({ when_minGovernance: '5' }, profile, emptyRepo);
695
+ expect(result.included).toBe(true);
696
+ expect(result.reasons.some((r) => r.includes('satisfied'))).toBe(true);
697
+ });
698
+ // 9. when_minGovernance with too few → excluded
699
+ it('when_minGovernance with too few relations → excluded', () => {
700
+ const lowRelProfile = { stage: 'grounded', totalRelations: 3 };
701
+ const result = evaluateConditions({ when_minGovernance: '5' }, lowRelProfile, emptyRepo);
702
+ expect(result.included).toBe(false);
703
+ expect(result.reasons.some((r) => r.includes('when_minGovernance=5'))).toBe(true);
704
+ });
705
+ // 10. Multiple conditions: all pass → included
706
+ it('multiple conditions: all pass → included', () => {
707
+ const result = evaluateConditions({ when_stack: 'sveltekit', when_minStage: 'seed', when_minGovernance: '5' }, profile, sveltekitRepo);
708
+ expect(result.included).toBe(true);
709
+ });
710
+ // 11. Multiple conditions: one fails → excluded (AND semantics)
711
+ it('multiple conditions: one fails → excluded', () => {
712
+ const result = evaluateConditions({ when_stack: 'sveltekit', when_minStage: 'critical' }, profile, // stage=grounded, below critical
713
+ sveltekitRepo);
714
+ expect(result.included).toBe(false);
715
+ expect(result.reasons.some((r) => r.includes('when_minStage=critical'))).toBe(true);
716
+ });
717
+ // 12. Null profile + when_minStage → included (fail-open)
718
+ it('null profile with when_minStage → fail-open (included)', () => {
719
+ const result = evaluateConditions({ when_minStage: 'grounded' }, noProfile, emptyRepo);
720
+ expect(result.included).toBe(true);
721
+ expect(result.reasons.some((r) => r.includes('fail-open'))).toBe(true);
722
+ });
723
+ // 13. Null profile + when_stack only → still evaluated (stack comes from repoContext)
724
+ it('null profile with when_stack only → still evaluated from repoContext', () => {
725
+ const matchResult = evaluateConditions({ when_stack: 'sveltekit' }, noProfile, sveltekitRepo);
726
+ expect(matchResult.included).toBe(true);
727
+ const noMatchResult = evaluateConditions({ when_stack: 'nextjs' }, noProfile, sveltekitRepo);
728
+ expect(noMatchResult.included).toBe(false);
729
+ });
730
+ });
731
+ describe('BET-170 acceptance criteria — integration', () => {
732
+ /**
733
+ * Mock rules cover all combinations: stack condition, stage condition,
734
+ * governance condition, and unconditional at core/intermediate/expert levels.
735
+ */
736
+ const mockRules = [
737
+ { name: 'deployment', level: 'core', conditions: { when_stack: 'sveltekit' } },
738
+ { name: 'feature-flags', level: 'intermediate', conditions: { when_minStage: 'grounded' } },
739
+ { name: 'domain-boundaries', level: 'expert', conditions: { when_minGovernance: '5' } },
740
+ { name: 'code-integrity', level: 'core' }, // no conditions — always included
741
+ { name: 'review-gate', level: 'intermediate' }, // no conditions
742
+ { name: 'orchestrator-mode', level: 'expert' }, // no conditions
743
+ ];
744
+ function applyPipeline(rules, profile, repo) {
745
+ const profileStage = profile?.stage;
746
+ // stage-gated level filter: no requestedLevel (defaults to widest), stage caps it
747
+ const levelFiltered = filterByLevel(rules, undefined, profileStage);
748
+ // condition filter
749
+ return levelFiltered.filter((rule) => {
750
+ const result = evaluateConditions(rule.conditions ?? {}, profile, repo);
751
+ return result.included;
752
+ });
753
+ }
754
+ // AC1: Fresh Python workspace (stage=blank, 0 entries) — only core items with no conditions
755
+ it('AC1: stage=blank, python — only core unconditional items', () => {
756
+ const profile = { stage: 'blank', totalEntries: 0, totalRelations: 0 };
757
+ const repo = { detectedStack: ['python'] };
758
+ const result = applyPipeline(mockRules, profile, repo);
759
+ const names = result.map((r) => r.name);
760
+ // Only code-integrity: core level, no conditions
761
+ // deployment: core but when_stack=sveltekit fails (python != sveltekit)
762
+ expect(names).toContain('code-integrity');
763
+ expect(names).not.toContain('deployment'); // core but sveltekit condition fails
764
+ expect(names).not.toContain('feature-flags'); // intermediate — capped by blank stage
765
+ expect(names).not.toContain('domain-boundaries'); // expert — capped by blank stage
766
+ expect(names).not.toContain('review-gate'); // intermediate — capped by blank stage
767
+ expect(names).not.toContain('orchestrator-mode'); // expert — capped by blank stage
768
+ expect(result).toHaveLength(1);
769
+ });
770
+ // AC2: Node/TS workspace (stage=grounded, 30 entries) — stack-matched, all levels
771
+ it('AC2: stage=grounded, sveltekit/typescript, 30 entries — all rules included', () => {
772
+ const profile = { stage: 'grounded', totalEntries: 30, totalRelations: 15 };
773
+ const repo = { detectedStack: ['typescript', 'sveltekit'] };
774
+ const result = applyPipeline(mockRules, profile, repo);
775
+ const names = result.map((r) => r.name);
776
+ // All conditions pass: sveltekit matches, grounded >= grounded, 15 >= 5
777
+ expect(names).toContain('deployment');
778
+ expect(names).toContain('feature-flags');
779
+ expect(names).toContain('domain-boundaries');
780
+ expect(names).toContain('code-integrity');
781
+ expect(names).toContain('review-gate');
782
+ expect(names).toContain('orchestrator-mode');
783
+ expect(result).toHaveLength(6);
784
+ });
785
+ // AC3: Mature workspace (stage=connected, 100+ entries) — all rules and skills
786
+ it('AC3: stage=connected, 150 entries — all rules projected without filtering', () => {
787
+ const profile = { stage: 'connected', totalEntries: 150, totalRelations: 80 };
788
+ const repo = { detectedStack: ['typescript', 'sveltekit'] };
789
+ const result = applyPipeline(mockRules, profile, repo);
790
+ expect(result).toHaveLength(6);
791
+ });
792
+ // AC4: Unknown stack — general template set, no stack-specific rules
793
+ it('AC4: stage=grounded, unknown stack — only non-stack-conditional rules', () => {
794
+ const profile = { stage: 'grounded', totalEntries: 30, totalRelations: 15 };
795
+ const repo = { detectedStack: [] };
796
+ const result = applyPipeline(mockRules, profile, repo);
797
+ const names = result.map((r) => r.name);
798
+ // deployment requires sveltekit stack — excluded
799
+ // all others: feature-flags (grounded ok), domain-boundaries (15 >= 5), unconditionals all pass
800
+ expect(names).not.toContain('deployment');
801
+ expect(names).toContain('feature-flags');
802
+ expect(names).toContain('domain-boundaries');
803
+ expect(names).toContain('code-integrity');
804
+ expect(names).toContain('review-gate');
805
+ expect(names).toContain('orchestrator-mode');
806
+ expect(result).toHaveLength(5);
807
+ });
808
+ // AC5: workspaceReadiness failure (null profile) — fail-open, all rules/skills project
809
+ it('AC5: null profile (readiness failure) — fail-open, profile conditions pass', () => {
810
+ const profile = null;
811
+ const repo = { detectedStack: ['typescript'] };
812
+ const result = applyPipeline(mockRules, profile, repo);
813
+ const names = result.map((r) => r.name);
814
+ // Null profile:
815
+ // - filterByLevel: no stage cap → all levels pass
816
+ // - evaluateConditions: when_minStage + when_minGovernance → fail-open (included)
817
+ // - when_stack: still evaluated against repoContext; sveltekit not in [typescript] → excluded
818
+ expect(names).not.toContain('deployment'); // when_stack=sveltekit, typescript doesn't match
819
+ expect(names).toContain('feature-flags'); // fail-open (profile null)
820
+ expect(names).toContain('domain-boundaries'); // fail-open (profile null)
821
+ expect(names).toContain('code-integrity');
822
+ expect(names).toContain('review-gate');
823
+ expect(names).toContain('orchestrator-mode');
824
+ expect(result).toHaveLength(5);
825
+ });
826
+ // Scale test: 0/5/50/500 entries — filtering correctness at different entry counts
827
+ it('scale test: filtering correctness at 0/5/50/500 totalEntries', () => {
828
+ const repo = { detectedStack: ['sveltekit'] };
829
+ // 0 entries: blank stage, low relations
830
+ // deployment: core level (passes stage cap), sveltekit matches → included
831
+ // code-integrity: core level, no conditions → included
832
+ // everything else: capped by blank stage (intermediate/expert excluded)
833
+ const result0 = applyPipeline(mockRules, { stage: 'blank', totalEntries: 0, totalRelations: 0 }, repo);
834
+ const names0 = result0.map((r) => r.name);
835
+ expect(names0).toContain('deployment');
836
+ expect(names0).toContain('code-integrity');
837
+ expect(names0).not.toContain('feature-flags'); // intermediate — capped
838
+ expect(names0).not.toContain('domain-boundaries'); // expert — capped
839
+ expect(names0).not.toContain('review-gate'); // intermediate — capped
840
+ expect(names0).not.toContain('orchestrator-mode'); // expert — capped
841
+ expect(result0).toHaveLength(2);
842
+ // 5 entries: seed stage, 2 relations (below governance threshold)
843
+ const result5 = applyPipeline(mockRules, { stage: 'seed', totalEntries: 5, totalRelations: 2 }, repo);
844
+ const names5 = result5.map((r) => r.name);
845
+ // seed caps at intermediate; domain-boundaries (expert) + orchestrator-mode (expert) excluded
846
+ // domain-boundaries also fails governance (2 < 5)
847
+ // feature-flags: intermediate ok, but when_minStage=grounded fails for seed → excluded
848
+ // deployment: core ok, sveltekit matches → included
849
+ expect(names5).toContain('deployment');
850
+ expect(names5).toContain('code-integrity');
851
+ expect(names5).toContain('review-gate');
852
+ expect(names5).not.toContain('domain-boundaries');
853
+ expect(names5).not.toContain('orchestrator-mode');
854
+ expect(names5).not.toContain('feature-flags');
855
+ // 50 entries: grounded stage, 20 relations
856
+ const result50 = applyPipeline(mockRules, { stage: 'grounded', totalEntries: 50, totalRelations: 20 }, repo);
857
+ expect(result50).toHaveLength(6);
858
+ // 500 entries: connected stage, 200 relations
859
+ const result500 = applyPipeline(mockRules, { stage: 'connected', totalEntries: 500, totalRelations: 200 }, repo);
860
+ expect(result500).toHaveLength(6);
861
+ });
862
+ });
863
+ describe('readCanonicalRules — backward compat with when_* frontmatter', () => {
864
+ beforeEach(() => {
865
+ Object.keys(vfs).forEach((k) => delete vfs[k]);
866
+ });
867
+ it('parses when_stack from frontmatter into conditions', () => {
868
+ vfs[join(PB_DIR, 'rules', 'deploy.md')] = `---
869
+ name: deploy
870
+ description: Deploy rule
871
+ scope: ""
872
+ autoApply: true
873
+ when_stack: sveltekit
874
+ ---
875
+
876
+ # Deploy body
877
+ `;
878
+ const rules = readCanonicalRules(PB_DIR);
879
+ expect(rules).toHaveLength(1);
880
+ expect(rules[0].conditions?.when_stack).toBe('sveltekit');
881
+ });
882
+ it('parses when_minStage from frontmatter into conditions', () => {
883
+ vfs[join(PB_DIR, 'rules', 'flags.md')] = `---
884
+ name: flags
885
+ description: Feature flags rule
886
+ scope: ""
887
+ autoApply: false
888
+ when_minStage: grounded
889
+ ---
890
+
891
+ # Flags body
892
+ `;
893
+ const rules = readCanonicalRules(PB_DIR);
894
+ expect(rules).toHaveLength(1);
895
+ expect(rules[0].conditions?.when_minStage).toBe('grounded');
896
+ });
897
+ it('parses when_minGovernance from frontmatter into conditions', () => {
898
+ vfs[join(PB_DIR, 'rules', 'domains.md')] = `---
899
+ name: domains
900
+ description: Domain boundaries rule
901
+ scope: ""
902
+ autoApply: true
903
+ when_minGovernance: 5
904
+ ---
905
+
906
+ # Domains body
907
+ `;
908
+ const rules = readCanonicalRules(PB_DIR);
909
+ expect(rules).toHaveLength(1);
910
+ expect(rules[0].conditions?.when_minGovernance).toBe('5');
911
+ });
912
+ it('returns undefined conditions when no when_* keys are present (backward compat)', () => {
913
+ vfs[join(PB_DIR, 'rules', 'plain.md')] = `---
914
+ name: plain
915
+ description: Plain rule
916
+ scope: ""
917
+ autoApply: true
918
+ ---
919
+
920
+ # Plain body
921
+ `;
922
+ const rules = readCanonicalRules(PB_DIR);
923
+ expect(rules).toHaveLength(1);
924
+ expect(rules[0].conditions).toBeUndefined();
925
+ });
926
+ });
399
927
  //# sourceMappingURL=portable-knowledge.test.js.map