@productbrain/cli 0.1.0-beta.5984 → 0.1.0-beta.5993

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.
@@ -13,17 +13,16 @@
13
13
  * - drift report classification (3 buckets) is correct for the fixture,
14
14
  * - PB-managed projection paths are inside the perimeter,
15
15
  * - user-owned files are NEVER touched,
16
- * - lock metadata in the manifest is read+honoured by the perimeter,
17
- * - dormant markers are idempotent across re-runs.
16
+ * - lock metadata in the manifest is read+honoured by the perimeter.
18
17
  *
19
18
  * The four rows share ONE tempdir (per spec) — failures localize to the
20
19
  * `transitionName` column.
21
20
  */
22
- import { mkdtempSync, rmSync, mkdirSync, readFileSync, writeFileSync, existsSync, renameSync } from 'fs';
21
+ import { mkdtempSync, rmSync, mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs';
23
22
  import { tmpdir } from 'os';
24
23
  import { join, dirname } from 'path';
25
24
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
26
- import { classifyDriftBucket, DORMANT_MARKER, writeDormantMarkerToFile, } from '../commands/handshake.js';
25
+ import { classifyDriftBucket, } from '../commands/handshake.js';
27
26
  import { MARKER } from '../generators/adapters.js';
28
27
  import { assertSetupWritePath, SETUP_PERIMETER_VIOLATION } from '../setup/perimeter.js';
29
28
  import { readManifestStatus } from '../generators/manifest.js';
@@ -97,28 +96,24 @@ const ROWS = [
97
96
  fromMode: 'off',
98
97
  toMode: 'observe',
99
98
  writesProjections: false,
100
- lowersToDormant: false,
101
99
  },
102
100
  {
103
101
  transitionName: 'observe → project',
104
102
  fromMode: 'observe',
105
103
  toMode: 'project',
106
104
  writesProjections: true,
107
- lowersToDormant: false,
108
105
  },
109
106
  {
110
107
  transitionName: 'project → govern',
111
108
  fromMode: 'project',
112
109
  toMode: 'govern',
113
110
  writesProjections: true,
114
- lowersToDormant: false,
115
111
  },
116
112
  {
117
113
  transitionName: 'govern → off',
118
114
  fromMode: 'govern',
119
115
  toMode: 'off',
120
116
  writesProjections: false,
121
- lowersToDormant: true,
122
117
  },
123
118
  ];
124
119
  // ── Per-row assertions ───────────────────────────────────────────────────────
@@ -173,39 +168,13 @@ describe('WP-421 S7 — table-driven E2E (doneWhen #27): four authority transiti
173
168
  it('user-owned file is untouched after the transition (no MARKER, never written)', () => {
174
169
  // Per row 2 (observe → project): "PB-managed projected, user-owned untouched."
175
170
  // We assert the same invariant on every row — user-owned never gains a
176
- // MARKER, never gains a dormant marker, never gets overwritten.
171
+ // MARKER, never gets overwritten.
177
172
  const userOwnedPath = join(process.cwd(), FIXTURE_FILES.userOwned);
178
173
  const before = readFileSync(userOwnedPath, 'utf8');
179
- // Simulate "what would PB do?" — try to drop a dormant marker. The
180
- // function must skip user-owned files (no MARKER → return 'skipped').
181
- const dormantResult = writeDormantMarkerToFile(userOwnedPath);
182
- expect(dormantResult).toBe('skipped');
183
174
  const after = readFileSync(userOwnedPath, 'utf8');
184
175
  expect(after).toBe(before);
185
176
  expect(after.includes(MARKER)).toBe(false);
186
- expect(after.includes(DORMANT_MARKER)).toBe(false);
187
177
  });
188
- if (row.lowersToDormant) {
189
- it('govern → off: dormant markers are placed on PB-managed files, idempotent on re-run', () => {
190
- // Row 4 only. govern → off lowers all assets to dormant. The marker
191
- // must be written exactly once even if the handshake runs N times in
192
- // a row (doneWhen #20 idempotency).
193
- const cleanPath = join(process.cwd(), FIXTURE_FILES.pbManagedClean);
194
- // First call: marker is appended.
195
- const first = writeDormantMarkerToFile(cleanPath);
196
- expect(first).toBe('written');
197
- // Second + third call: idempotent — must NOT append a second marker.
198
- const second = writeDormantMarkerToFile(cleanPath);
199
- const third = writeDormantMarkerToFile(cleanPath);
200
- expect(second).toBe('already-dormant');
201
- expect(third).toBe('already-dormant');
202
- const content = readFileSync(cleanPath, 'utf8');
203
- // Exactly one occurrence of DORMANT_MARKER.
204
- const escaped = DORMANT_MARKER.replace(/[<>!-]/g, '\\$&');
205
- const occurrences = (content.match(new RegExp(escaped, 'g')) ?? []).length;
206
- expect(occurrences).toBe(1);
207
- });
208
- }
209
178
  if (row.transitionName === 'project → govern') {
210
179
  it('project → govern: lock metadata in manifest survives the transition (.lock honored)', () => {
211
180
  // The manifest declares SETUP-RULE-PB-ORIENT: platform. This is the
@@ -515,741 +484,4 @@ describe('WP-426 E3 (Loop fix): runHandshake round-trip — authoringPath lands
515
484
  expect(allStderr).not.toContain('could not dormant-mark');
516
485
  });
517
486
  });
518
- // ── WP-426 E4: lowering — .dormant rename + purge cue + authoring copy untouched ─
519
- //
520
- // Drives runHandshake() with a kernel stub that returns the Foo asset in
521
- // dormantAssets. Pre-places the projected surface files (with MARKER) and the
522
- // .productbrain authoring copy. After runHandshake({apply:true}):
523
- // - .codex/skills/Foo.md.dormant ← renamed from .codex/skills/Foo.md
524
- // - .cursor/rules/Foo.mdc.dormant ← renamed from .cursor/rules/Foo.mdc
525
- // - originals (.md / .mdc) are gone
526
- // - .productbrain/skills/Foo.md is UNTOUCHED (deriveDormantFilePaths never returns .productbrain paths)
527
- // - stdout contains the purge cue
528
- //
529
- // Manual reactivation:
530
- // After the lowering run, simulate the user renaming .codex/skills/Foo.md.dormant
531
- // back to .codex/skills/Foo.md. Re-run with the asset still dormant. Assert:
532
- // - .codex/skills/Foo.md is untouched (no re-dormant)
533
- // - a drift TEN was queued via kernelCallWithSession('chain.createEntry', …)
534
- describe('WP-426 E4: lowering — .dormant rename + purge cue + authoring copy untouched', () => {
535
- let e2eRoot;
536
- let cwdSpy;
537
- let stdoutLines;
538
- let kernelCallWithSessionMock;
539
- // ── Shared stub factory ───────────────────────────────────────────────────
540
- async function setupMocks(opts = {}) {
541
- vi.resetModules();
542
- e2eRoot = mkdtempSync(join(tmpdir(), 'pb-wp426-e4-lower-'));
543
- mkdirSync(join(e2eRoot, '.productbrain', 'skills'), { recursive: true });
544
- mkdirSync(join(e2eRoot, '.codex', 'skills'), { recursive: true });
545
- mkdirSync(join(e2eRoot, '.cursor', 'rules'), { recursive: true });
546
- // Real manifest.yaml — mode='project' so writeMode is active
547
- writeFileSync(join(e2eRoot, '.productbrain', 'manifest.yaml'), ['version: "0.1"', 'materialize: project', 'surfaces:', ' - .codex', ' - .cursor', ' - .claude', ''].join('\n'));
548
- cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(e2eRoot);
549
- stdoutLines = [];
550
- vi.spyOn(process.stdout, 'write').mockImplementation((chunk, ...rest) => {
551
- if (typeof chunk === 'string')
552
- stdoutLines.push(chunk);
553
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
554
- return process.stdout.write.__originalImpl ? true : true;
555
- });
556
- kernelCallWithSessionMock = vi.fn().mockResolvedValue(null);
557
- vi.doMock('../lib/client.js', () => ({
558
- kernelCall: vi.fn().mockImplementation(async (tool) => {
559
- if (tool === 'chain.workspaceReadiness')
560
- return null;
561
- if (tool === 'chain.getOrientView')
562
- return null;
563
- if (tool === 'chain.searchEntries')
564
- return [];
565
- if (tool === 'setup.listAssetsForUser') {
566
- return {
567
- activeAssets: opts.activeAssets ?? [],
568
- dormantAssets: opts.dormantAssets ?? [
569
- {
570
- entryId: 'SETUP-SKILL-FOO',
571
- name: 'Foo',
572
- description: 'A dormant skill',
573
- body: '# Foo\nSkill body.',
574
- triggers: [],
575
- assetKind: 'skill',
576
- semanticRefs: [],
577
- disabledByOwner: false,
578
- lastProjectedHash: null,
579
- },
580
- {
581
- entryId: 'SETUP-RULE-FOO',
582
- name: 'Foo',
583
- description: 'A dormant rule',
584
- body: '# Foo\nRule body.',
585
- triggers: [],
586
- assetKind: 'rule',
587
- semanticRefs: [],
588
- disabledByOwner: false,
589
- lastProjectedHash: null,
590
- },
591
- ],
592
- hasAnyReceipt: true,
593
- };
594
- }
595
- if (tool === 'setup.updateLastProjectedHash')
596
- return { ok: true };
597
- if (tool === 'setup.getCurrentSetupState')
598
- return { effectiveMode: 'project' };
599
- if (tool === 'setup.materializeSetup')
600
- return { ok: true, assetCount: 0, receiptWritten: true };
601
- return null;
602
- }),
603
- kernelCallWithSession: kernelCallWithSessionMock,
604
- }));
605
- vi.doMock('../lib/config.js', () => ({
606
- getConfigOrGuide: vi.fn().mockResolvedValue({ apiKey: 'pb_sk_e2e_test', siteUrl: 'https://test.convex.site' }),
607
- }));
608
- vi.doMock('../lib/repo-detect.js', () => ({
609
- detectRepo: vi.fn().mockReturnValue({ name: 'e2e-test-repo', detectedStack: [], repoSlug: null }),
610
- extractWorkspaceProfile: vi.fn().mockReturnValue(null),
611
- }));
612
- vi.doMock('../generators/adapters.js', () => ({
613
- MARKER: 'auto-generated by pb handshake',
614
- generateAgentsMd: vi.fn().mockReturnValue('<!-- auto-generated by pb handshake -->\nagents'),
615
- generateClaudeMd: vi.fn().mockReturnValue('<!-- auto-generated by pb handshake -->\nclaude'),
616
- generateCursorMdc: vi.fn().mockReturnValue('<!-- auto-generated by pb handshake -->\ncursor'),
617
- generateCopilotMd: vi.fn().mockReturnValue('<!-- auto-generated by pb handshake -->\ncopilot'),
618
- generateCopilotInstructionFiles: vi.fn().mockReturnValue([]),
619
- }));
620
- vi.doMock('../generators/portable-knowledge.js', () => ({
621
- readCanonicalSkills: vi.fn().mockReturnValue([]),
622
- readCanonicalRules: vi.fn().mockReturnValue([]),
623
- readPersonalLayer: vi.fn().mockReturnValue([]),
624
- readPersonalSkillsLayer: vi.fn().mockReturnValue([]),
625
- generateCursorSkill: vi.fn().mockReturnValue(''),
626
- generateCursorRule: vi.fn().mockReturnValue(''),
627
- generateCodexSkill: vi.fn().mockReturnValue(''),
628
- generateClaudeRule: vi.fn().mockReturnValue(''),
629
- generateCodexSkillIndex: vi.fn().mockReturnValue(''),
630
- generateClaudeSkillRouter: vi.fn().mockReturnValue(''),
631
- shouldEmitToTarget: vi.fn().mockReturnValue(false),
632
- filterByLevel: vi.fn().mockImplementation((items) => items),
633
- evaluateConditions: vi.fn().mockReturnValue({ included: true, reasons: [] }),
634
- validateCodexSkills: vi.fn().mockReturnValue([]),
635
- STAGE_TO_MAX_LEVEL: {},
636
- LEVEL_ORDER: [],
637
- }));
638
- vi.doMock('../generators/briefing-md.js', () => ({ generateBriefingMd: vi.fn().mockReturnValue('briefing') }));
639
- vi.doMock('../generators/context-md.js', () => ({ generateContextMd: vi.fn().mockReturnValue('context') }));
640
- vi.doMock('../generators/chain-rules.js', () => ({
641
- generateChainRules: vi.fn().mockResolvedValue({ content: '', rules: [], classified: {}, stats: { generatedRules: 0, suppressedByManual: 0, suppressedByZeroEntries: 0 }, gaps: [], sentinel: null }),
642
- }));
643
- vi.doMock('../generators/handshake-diff.js', () => ({
644
- saveHandshakeState: vi.fn(),
645
- loadPreviousState: vi.fn().mockReturnValue(null),
646
- diffHandshakeState: vi.fn().mockReturnValue({ added: [], updated: [], removed: [] }),
647
- formatDiff: vi.fn().mockReturnValue(''),
648
- buildCurrentState: vi.fn().mockReturnValue({}),
649
- }));
650
- vi.doMock('../generators/surface-profiles.js', () => ({
651
- resolveSurfaceProfile: vi.fn().mockReturnValue({ level: 'intermediate' }),
652
- }));
653
- vi.doMock('../generators/boundary-manifest.js', () => ({
654
- generateBoundaryManifest: vi.fn().mockReturnValue(null),
655
- getBoundaryEnforcementMode: vi.fn().mockReturnValue('advisory'),
656
- }));
657
- vi.doMock('../formatters/handshake.js', () => ({ formatHandshakeReport: vi.fn().mockReturnValue('') }));
658
- vi.doMock('../lib/hook-intents.js', () => ({
659
- composeHooksFromIntents: vi.fn().mockReturnValue({}),
660
- getHookStatusForSurface: vi.fn().mockReturnValue([]),
661
- }));
662
- vi.doMock('../lib/method-registry.js', () => ({
663
- loadMethodRegistry: vi.fn().mockResolvedValue({ methods: [], source: 'bundled', stale: false }),
664
- }));
665
- vi.doMock('../lib/workspaceVocabCache.js', () => ({ getOrFetchVocabCtx: vi.fn().mockResolvedValue(null) }));
666
- vi.doMock('../lib/telemetry.js', () => ({ trackEvent: vi.fn() }));
667
- vi.doMock('../lib/session.js', () => ({
668
- readSession: vi.fn().mockReturnValue({ sessionId: 'test-session-id', userId: 'test-user' }),
669
- }));
670
- vi.doMock('../lib/canonicalRefs.js', () => ({
671
- parseSemanticRefs: vi.fn().mockReturnValue([]),
672
- replaceVocabTokens: vi.fn().mockImplementation((content) => content),
673
- }));
674
- vi.doMock('../surfaces/telemetry.js', () => ({
675
- getReverseMapFallbackMessage: vi.fn().mockReturnValue(''),
676
- reportReverseMapMissing: vi.fn(),
677
- }));
678
- }
679
- afterEach(() => {
680
- cwdSpy?.mockRestore();
681
- if (e2eRoot && existsSync(e2eRoot))
682
- rmSync(e2eRoot, { recursive: true, force: true });
683
- vi.restoreAllMocks();
684
- });
685
- it('E4: lowering — .dormant files created for ALL surface paths (skill + rule), originals gone, authoring copy untouched, purge cue in stdout', async () => {
686
- await setupMocks();
687
- // FIX 3: pre-place ALL four surface paths that deriveDormantFilePaths emits
688
- // for the two dormant assets (skill "Foo" + rule "Foo"):
689
- // skill → .cursor/skills/Foo/SKILL.md (cursor skill subdir)
690
- // + .codex/skills/Foo.md (codex skill)
691
- // rule → .cursor/rules/Foo.mdc (cursor rule)
692
- // + .claude/rules/Foo.md (claude rule)
693
- mkdirSync(join(e2eRoot, '.cursor', 'skills', 'Foo'), { recursive: true });
694
- mkdirSync(join(e2eRoot, '.claude', 'rules'), { recursive: true });
695
- const cursorSkillPath = join(e2eRoot, '.cursor', 'skills', 'Foo', 'SKILL.md');
696
- const codexSkillPath = join(e2eRoot, '.codex', 'skills', 'Foo.md');
697
- const cursorRulePath = join(e2eRoot, '.cursor', 'rules', 'Foo.mdc');
698
- const claudeRulePath = join(e2eRoot, '.claude', 'rules', 'Foo.md');
699
- const authoringCopy = join(e2eRoot, '.productbrain', 'skills', 'Foo.md');
700
- const markedContent = (label) => `<!-- auto-generated by pb handshake -->\n# Foo ${label}\n`;
701
- writeFileSync(cursorSkillPath, markedContent('cursor-skill'));
702
- writeFileSync(codexSkillPath, markedContent('codex-skill'));
703
- writeFileSync(cursorRulePath, markedContent('cursor-rule'));
704
- writeFileSync(claudeRulePath, markedContent('claude-rule'));
705
- writeFileSync(authoringCopy, '---\nid: SETUP-SKILL-FOO\nname: "Foo"\n---\nHand-authored content.\n');
706
- const { runHandshake } = await import('../commands/handshake.js');
707
- await runHandshake({ apply: true });
708
- // ── All four .dormant files must be created
709
- expect(existsSync(`${cursorSkillPath}.dormant`), '.cursor/skills/Foo/SKILL.md.dormant must exist').toBe(true);
710
- expect(existsSync(`${codexSkillPath}.dormant`), '.codex/skills/Foo.md.dormant must exist').toBe(true);
711
- expect(existsSync(`${cursorRulePath}.dormant`), '.cursor/rules/Foo.mdc.dormant must exist').toBe(true);
712
- expect(existsSync(`${claudeRulePath}.dormant`), '.claude/rules/Foo.md.dormant must exist').toBe(true);
713
- // ── All four originals must be gone
714
- expect(existsSync(cursorSkillPath), '.cursor/skills/Foo/SKILL.md original must be gone').toBe(false);
715
- expect(existsSync(codexSkillPath), '.codex/skills/Foo.md original must be gone').toBe(false);
716
- expect(existsSync(cursorRulePath), '.cursor/rules/Foo.mdc original must be gone').toBe(false);
717
- expect(existsSync(claudeRulePath), '.claude/rules/Foo.md original must be gone').toBe(false);
718
- // ── .productbrain authoring copy must be untouched
719
- expect(existsSync(authoringCopy), '.productbrain/skills/Foo.md authoring copy must be untouched').toBe(true);
720
- // ── purge cue in stdout
721
- const allOutput = stdoutLines.join('');
722
- expect(allOutput, 'stdout must contain the purge cue').toContain('pb setup observe --purge');
723
- // ── .authoring-sync.json dormantRenamed registry must include all four surface paths
724
- const syncPath = join(e2eRoot, '.productbrain', '.authoring-sync.json');
725
- expect(existsSync(syncPath), '.authoring-sync.json must exist').toBe(true);
726
- const syncState = JSON.parse(readFileSync(syncPath, 'utf8'));
727
- expect(syncState.dormantRenamed, 'dormantRenamed must include cursor skill subdir path').toContain('.cursor/skills/Foo/SKILL.md');
728
- expect(syncState.dormantRenamed, 'dormantRenamed must include codex skill path').toContain('.codex/skills/Foo.md');
729
- expect(syncState.dormantRenamed, 'dormantRenamed must include cursor rule path').toContain('.cursor/rules/Foo.mdc');
730
- expect(syncState.dormantRenamed, 'dormantRenamed must include claude rule path').toContain('.claude/rules/Foo.md');
731
- });
732
- it('E4: manual reactivation — reactivated file left untouched, drift TEN queued ONCE; N+3 run never re-dormants', async () => {
733
- // FIX 2: Strengthened reactivation test.
734
- // Run N+1 (detection): pre-staged state → TEN fires, path moves to dormantReactivated.
735
- // Run N+2 (hands-off): path in dormantReactivated → skipped silently, file still live, NO second TEN.
736
- await setupMocks();
737
- const skillPath = join(e2eRoot, '.codex', 'skills', 'Foo.md');
738
- // ── Pre-stage a post-lowering filesystem state:
739
- // • .codex/skills/Foo.md.dormant exists (was previously lowered)
740
- // • .productbrain/.authoring-sync.json already has the registry entry
741
- // • The user has then renamed .dormant back to .md (manual reactivation)
742
- //
743
- // We do NOT run a first handshake — we directly write the state that would
744
- // exist after a prior lowering run. This avoids module-reset issues between
745
- // two sequential runHandshake calls in the same test.
746
- // .dormant file from prior lowering (user will "manually rename" it back)
747
- writeFileSync(`${skillPath}.dormant`, '<!-- auto-generated by pb handshake -->\n# Foo\nSkill body.\n<!-- pb-status: dormant -->\n');
748
- // Simulate user renaming .dormant → live (manual reactivation)
749
- renameSync(`${skillPath}.dormant`, skillPath);
750
- expect(existsSync(skillPath)).toBe(true);
751
- expect(existsSync(`${skillPath}.dormant`)).toBe(false);
752
- // Pre-write the .authoring-sync.json registry as if the prior lowering run had done it.
753
- // Only skill path in dormantRenamed; no dormantReactivated yet.
754
- const syncPath = join(e2eRoot, '.productbrain', '.authoring-sync.json');
755
- writeFileSync(syncPath, JSON.stringify({
756
- version: 1,
757
- assets: {},
758
- dormantRenamed: ['.codex/skills/Foo.md', '.cursor/rules/Foo.mdc'],
759
- dormantReactivated: [],
760
- }, null, 2) + '\n');
761
- // Also ensure the rule .dormant is present (not manually reactivated, only the skill was)
762
- mkdirSync(join(e2eRoot, '.cursor', 'rules'), { recursive: true });
763
- const ruleDormantPath = join(e2eRoot, '.cursor', 'rules', 'Foo.mdc.dormant');
764
- writeFileSync(ruleDormantPath, '<!-- auto-generated by pb handshake -->\n# Foo\nRule body.\n<!-- pb-status: dormant -->\n');
765
- // ── Run N+1: FIRST detection of manual reactivation
766
- const { runHandshake: runHandshake1 } = await import('../commands/handshake.js');
767
- await runHandshake1({ apply: true });
768
- // ── After N+1: reactivated file must remain live
769
- expect(existsSync(skillPath), 'N+1: .codex/skills/Foo.md must remain live (user reactivated it)').toBe(true);
770
- expect(existsSync(`${skillPath}.dormant`), 'N+1: .dormant must NOT be recreated for the reactivated file').toBe(false);
771
- // ── After N+1: .authoring-sync.json must have path in dormantReactivated, not dormantRenamed (FIX 2a)
772
- const syncState1 = JSON.parse(readFileSync(syncPath, 'utf8'));
773
- expect(syncState1.dormantReactivated, 'N+1: dormantReactivated must include the reactivated skill path').toContain('.codex/skills/Foo.md');
774
- expect(syncState1.dormantRenamed ?? [], 'N+1: dormantRenamed must NOT include the reactivated skill path').not.toContain('.codex/skills/Foo.md');
775
- // ── After N+1: exactly one drift TEN was queued
776
- const createEntryCalls1 = kernelCallWithSessionMock.mock.calls.filter((call) => call[0] === 'chain.createEntry');
777
- expect(createEntryCalls1.length, 'N+1: at least one chain.createEntry call (drift TEN) must be made').toBeGreaterThan(0);
778
- const driftCall1 = createEntryCalls1.find((call) => {
779
- const args = call[1];
780
- return (args.collectionSlug === 'tensions' &&
781
- typeof args.data?.description === 'string' &&
782
- args.data.description.includes('manually reactivated'));
783
- });
784
- expect(driftCall1, 'N+1: drift TEN for manual reactivation must be queued').toBeDefined();
785
- const tenCountAfterN1 = createEntryCalls1.filter((call) => {
786
- const args = call[1];
787
- return (args.collectionSlug === 'tensions' &&
788
- args.data.description?.includes('manually reactivated'));
789
- }).length;
790
- // ── Run N+2 (FIX 2b): asset still dormant, path now in dormantReactivated.
791
- // The hands-off set must prevent any re-dormant AND any second TEN.
792
- vi.resetModules();
793
- // Re-setup mocks for the second run (fresh module registry)
794
- await setupMocks();
795
- // Restore the .authoring-sync.json state that N+1 left (setupMocks creates a fresh e2eRoot, so we must
796
- // re-use the same root by restoring after setupMocks overwrites it).
797
- // Since setupMocks creates a new e2eRoot, we need to write the N+1 post-state into the new root.
798
- // Instead, we directly write the dormantReactivated state to simulate what N+1 wrote.
799
- writeFileSync(skillPath, '<!-- auto-generated by pb handshake -->\n# Foo\nSkill body.\n');
800
- writeFileSync(syncPath, JSON.stringify({
801
- version: 1,
802
- assets: {},
803
- dormantRenamed: ['.cursor/rules/Foo.mdc'], // skill moved out
804
- dormantReactivated: ['.codex/skills/Foo.md'], // skill in hands-off set
805
- }, null, 2) + '\n');
806
- // Rule dormant still present (untouched)
807
- writeFileSync(join(e2eRoot, '.cursor', 'rules', 'Foo.mdc.dormant'), '<!-- auto-generated by pb handshake -->\n# Foo\nRule body.\n<!-- pb-status: dormant -->\n');
808
- const { runHandshake: runHandshake2 } = await import('../commands/handshake.js');
809
- await runHandshake2({ apply: true });
810
- // ── After N+2: skill file must STILL be live (not re-dormanted)
811
- expect(existsSync(skillPath), 'N+2: .codex/skills/Foo.md must still be live (hands-off set)').toBe(true);
812
- expect(existsSync(`${skillPath}.dormant`), 'N+2: .dormant must NOT be created on the second run').toBe(false);
813
- // ── After N+2: NO new drift TEN for the already-reactivated path (TEN fires once)
814
- const createEntryCalls2 = kernelCallWithSessionMock.mock.calls.filter((call) => call[0] === 'chain.createEntry');
815
- const newReactivationTens = createEntryCalls2.filter((call) => {
816
- const args = call[1];
817
- return (args.collectionSlug === 'tensions' &&
818
- args.data.description?.includes('manually reactivated'));
819
- }).length;
820
- expect(newReactivationTens, 'N+2: no additional reactivation TEN must be queued (hands-off set; TEN fires once)').toBe(0);
821
- });
822
- // Codex P2: dormant renames must honor the run's --surfaces selection, not just the
823
- // perimeter. `--surfaces cursor` may only rename cursor surfaces; .claude/.codex must
824
- // be left untouched even though they are declared in manifest.surfaces.
825
- it('E4 (Codex P2): --surfaces cursor lowers only cursor surfaces; .claude/.codex untouched', async () => {
826
- await setupMocks();
827
- mkdirSync(join(e2eRoot, '.cursor', 'skills', 'Foo'), { recursive: true });
828
- mkdirSync(join(e2eRoot, '.claude', 'rules'), { recursive: true });
829
- const cursorSkillPath = join(e2eRoot, '.cursor', 'skills', 'Foo', 'SKILL.md');
830
- const codexSkillPath = join(e2eRoot, '.codex', 'skills', 'Foo.md');
831
- const cursorRulePath = join(e2eRoot, '.cursor', 'rules', 'Foo.mdc');
832
- const claudeRulePath = join(e2eRoot, '.claude', 'rules', 'Foo.md');
833
- const marked = (label) => `<!-- auto-generated by pb handshake -->\n# Foo ${label}\n`;
834
- writeFileSync(cursorSkillPath, marked('cursor-skill'));
835
- writeFileSync(codexSkillPath, marked('codex-skill'));
836
- writeFileSync(cursorRulePath, marked('cursor-rule'));
837
- writeFileSync(claudeRulePath, marked('claude-rule'));
838
- const { runHandshake } = await import('../commands/handshake.js');
839
- await runHandshake({ apply: true, surfaces: ['cursor'] });
840
- // ── cursor surfaces lowered to .dormant
841
- expect(existsSync(`${cursorSkillPath}.dormant`), 'cursor skill must be lowered').toBe(true);
842
- expect(existsSync(`${cursorRulePath}.dormant`), 'cursor rule must be lowered').toBe(true);
843
- expect(existsSync(cursorSkillPath), 'cursor skill original gone').toBe(false);
844
- expect(existsSync(cursorRulePath), 'cursor rule original gone').toBe(false);
845
- // ── non-requested surfaces left fully untouched (no .dormant, originals intact)
846
- expect(existsSync(`${codexSkillPath}.dormant`), 'codex must NOT be lowered (--surfaces cursor)').toBe(false);
847
- expect(existsSync(`${claudeRulePath}.dormant`), 'claude must NOT be lowered (--surfaces cursor)').toBe(false);
848
- expect(existsSync(codexSkillPath), 'codex skill original intact').toBe(true);
849
- expect(existsSync(claudeRulePath), 'claude rule original intact').toBe(true);
850
- });
851
- });
852
- // ── WP-426 E4: raise — orphan .dormant removed after re-projection; drift on edited copy ─
853
- //
854
- // Drives runHandshake() with a kernel stub. The raise-cleanup pass runs in the
855
- // `if (writeMode)` block after the dormant pass. It iterates over active assets,
856
- // calls deriveDormantFilePaths, and runs restoreSurfaceFromDormant(filePath).
857
- //
858
- // restoreSurfaceFromDormant compares the EXISTING fresh projection on disk against
859
- // the .dormant copy. In the real flow, the write loop re-projects the surface fresh
860
- // before the raise pass runs. In the test, we pre-place the fresh .md to simulate
861
- // what the write loop does (since readCanonicalSkills returns [] in these tests, the
862
- // write loop doesn't write .codex/skills/*.md via the codex skill loop).
863
- //
864
- // Test structure:
865
- // 1. Two-pass roundtrip: lower via runHandshake (pass 1) → pre-place fresh .md →
866
- // raise via runHandshake (pass 2). Assert .md exists, .dormant gone.
867
- // 2. Edited variant: pre-place edited .dormant + fresh .md → runHandshake →
868
- // assert .dormant preserved + drift TEN queued.
869
- // 3. Both-registries prune: pre-place with path in both dormantRenamed AND
870
- // dormantReactivated → raise → assert both pruned.
871
- describe('WP-426 E4: raise — orphan .dormant removed, drift on edited copy, both registries pruned', () => {
872
- let e2eRoot;
873
- let cwdSpy;
874
- let kernelCallWithSessionMock;
875
- // ── Shared mock setup factory — mirrors the E4 lowering pattern ──────────
876
- async function setupRaiseMocks(opts = {}) {
877
- vi.resetModules();
878
- e2eRoot = mkdtempSync(join(tmpdir(), 'pb-wp426-e4-raise-'));
879
- mkdirSync(join(e2eRoot, '.productbrain', 'skills'), { recursive: true });
880
- mkdirSync(join(e2eRoot, '.codex', 'skills'), { recursive: true });
881
- mkdirSync(join(e2eRoot, '.cursor', 'rules'), { recursive: true });
882
- mkdirSync(join(e2eRoot, '.cursor', 'skills', 'Foo'), { recursive: true });
883
- mkdirSync(join(e2eRoot, '.claude', 'rules'), { recursive: true });
884
- // Real manifest — mode='project' so writeMode is active
885
- writeFileSync(join(e2eRoot, '.productbrain', 'manifest.yaml'), ['version: "0.1"', 'materialize: project', 'surfaces:', ' - .codex', ' - .cursor', ' - .claude', ''].join('\n'));
886
- cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(e2eRoot);
887
- vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
888
- kernelCallWithSessionMock = vi.fn().mockResolvedValue(null);
889
- vi.doMock('../lib/client.js', () => ({
890
- kernelCall: vi.fn().mockImplementation(async (tool) => {
891
- if (tool === 'chain.workspaceReadiness')
892
- return null;
893
- if (tool === 'chain.getOrientView')
894
- return null;
895
- if (tool === 'chain.searchEntries')
896
- return [];
897
- if (tool === 'setup.listAssetsForUser') {
898
- return {
899
- activeAssets: opts.activeAssets ?? [],
900
- dormantAssets: opts.dormantAssets ?? [],
901
- hasAnyReceipt: true,
902
- };
903
- }
904
- if (tool === 'setup.updateLastProjectedHash')
905
- return { ok: true };
906
- if (tool === 'setup.getCurrentSetupState')
907
- return { effectiveMode: 'project' };
908
- if (tool === 'setup.materializeSetup')
909
- return { ok: true, assetCount: 0, receiptWritten: true };
910
- return null;
911
- }),
912
- kernelCallWithSession: kernelCallWithSessionMock,
913
- }));
914
- vi.doMock('../lib/config.js', () => ({
915
- getConfigOrGuide: vi.fn().mockResolvedValue({ apiKey: 'pb_sk_e2e_test', siteUrl: 'https://test.convex.site' }),
916
- }));
917
- vi.doMock('../lib/repo-detect.js', () => ({
918
- detectRepo: vi.fn().mockReturnValue({ name: 'e2e-test-repo', detectedStack: [], repoSlug: null }),
919
- extractWorkspaceProfile: vi.fn().mockReturnValue(null),
920
- }));
921
- vi.doMock('../generators/adapters.js', () => ({
922
- MARKER: 'auto-generated by pb handshake',
923
- generateAgentsMd: vi.fn().mockReturnValue('<!-- auto-generated by pb handshake -->\nagents'),
924
- generateClaudeMd: vi.fn().mockReturnValue('<!-- auto-generated by pb handshake -->\nclaude'),
925
- generateCursorMdc: vi.fn().mockReturnValue('<!-- auto-generated by pb handshake -->\ncursor'),
926
- generateCopilotMd: vi.fn().mockReturnValue('<!-- auto-generated by pb handshake -->\ncopilot'),
927
- generateCopilotInstructionFiles: vi.fn().mockReturnValue([]),
928
- }));
929
- vi.doMock('../generators/portable-knowledge.js', () => ({
930
- readCanonicalSkills: vi.fn().mockReturnValue([]),
931
- readCanonicalRules: vi.fn().mockReturnValue([]),
932
- readPersonalLayer: vi.fn().mockReturnValue([]),
933
- readPersonalSkillsLayer: vi.fn().mockReturnValue([]),
934
- generateCursorSkill: vi.fn().mockReturnValue(''),
935
- generateCursorRule: vi.fn().mockReturnValue(''),
936
- generateCodexSkill: vi.fn().mockReturnValue(''),
937
- generateClaudeRule: vi.fn().mockReturnValue(''),
938
- generateCodexSkillIndex: vi.fn().mockReturnValue(''),
939
- generateClaudeSkillRouter: vi.fn().mockReturnValue(''),
940
- shouldEmitToTarget: vi.fn().mockReturnValue(false),
941
- filterByLevel: vi.fn().mockImplementation((items) => items),
942
- evaluateConditions: vi.fn().mockReturnValue({ included: true, reasons: [] }),
943
- validateCodexSkills: vi.fn().mockReturnValue([]),
944
- STAGE_TO_MAX_LEVEL: {},
945
- LEVEL_ORDER: [],
946
- }));
947
- vi.doMock('../generators/briefing-md.js', () => ({ generateBriefingMd: vi.fn().mockReturnValue('briefing') }));
948
- vi.doMock('../generators/context-md.js', () => ({ generateContextMd: vi.fn().mockReturnValue('context') }));
949
- vi.doMock('../generators/chain-rules.js', () => ({
950
- generateChainRules: vi.fn().mockResolvedValue({ content: '', rules: [], classified: {}, stats: { generatedRules: 0, suppressedByManual: 0, suppressedByZeroEntries: 0 }, gaps: [], sentinel: null }),
951
- }));
952
- vi.doMock('../generators/handshake-diff.js', () => ({
953
- saveHandshakeState: vi.fn(),
954
- loadPreviousState: vi.fn().mockReturnValue(null),
955
- diffHandshakeState: vi.fn().mockReturnValue({ added: [], updated: [], removed: [] }),
956
- formatDiff: vi.fn().mockReturnValue(''),
957
- buildCurrentState: vi.fn().mockReturnValue({}),
958
- }));
959
- vi.doMock('../generators/surface-profiles.js', () => ({
960
- resolveSurfaceProfile: vi.fn().mockReturnValue({ level: 'intermediate' }),
961
- }));
962
- vi.doMock('../generators/boundary-manifest.js', () => ({
963
- generateBoundaryManifest: vi.fn().mockReturnValue(null),
964
- getBoundaryEnforcementMode: vi.fn().mockReturnValue('advisory'),
965
- }));
966
- vi.doMock('../formatters/handshake.js', () => ({ formatHandshakeReport: vi.fn().mockReturnValue('') }));
967
- vi.doMock('../lib/hook-intents.js', () => ({
968
- composeHooksFromIntents: vi.fn().mockReturnValue({}),
969
- getHookStatusForSurface: vi.fn().mockReturnValue([]),
970
- }));
971
- vi.doMock('../lib/method-registry.js', () => ({
972
- loadMethodRegistry: vi.fn().mockResolvedValue({ methods: [], source: 'bundled', stale: false }),
973
- }));
974
- vi.doMock('../lib/workspaceVocabCache.js', () => ({ getOrFetchVocabCtx: vi.fn().mockResolvedValue(null) }));
975
- vi.doMock('../lib/telemetry.js', () => ({ trackEvent: vi.fn() }));
976
- vi.doMock('../lib/session.js', () => ({
977
- readSession: vi.fn().mockReturnValue({ sessionId: 'test-session-id', userId: 'test-user' }),
978
- }));
979
- vi.doMock('../lib/canonicalRefs.js', () => ({
980
- parseSemanticRefs: vi.fn().mockReturnValue([]),
981
- replaceVocabTokens: vi.fn().mockImplementation((content) => content),
982
- }));
983
- vi.doMock('../surfaces/telemetry.js', () => ({
984
- getReverseMapFallbackMessage: vi.fn().mockReturnValue(''),
985
- reportReverseMapMissing: vi.fn(),
986
- }));
987
- }
988
- afterEach(() => {
989
- cwdSpy?.mockRestore();
990
- if (e2eRoot && existsSync(e2eRoot))
991
- rmSync(e2eRoot, { recursive: true, force: true });
992
- vi.restoreAllMocks();
993
- });
994
- it('E4: raise roundtrip — lower via runHandshake, then raise via runHandshake; .md exists, .dormant gone, registries pruned', async () => {
995
- // ── Pass 1: lower — asset in dormantAssets → .dormant rename
996
- await setupRaiseMocks({
997
- dormantAssets: [{
998
- entryId: 'SETUP-SKILL-FOO',
999
- name: 'Foo',
1000
- description: 'A skill',
1001
- body: '# Foo\nSkill body.',
1002
- triggers: [],
1003
- assetKind: 'skill',
1004
- semanticRefs: [],
1005
- disabledByOwner: false,
1006
- lastProjectedHash: null,
1007
- }],
1008
- activeAssets: [],
1009
- });
1010
- const codexSkillPath = join(e2eRoot, '.codex', 'skills', 'Foo.md');
1011
- const syncPath = join(e2eRoot, '.productbrain', '.authoring-sync.json');
1012
- // Pre-place the surface file with MARKER so the lowering pass can rename it
1013
- writeFileSync(codexSkillPath, '<!-- auto-generated by pb handshake -->\n# Foo\nSkill body.\n');
1014
- const { runHandshake: runLower } = await import('../commands/handshake.js');
1015
- await runLower({ apply: true });
1016
- // Verify lowering worked: .dormant exists, original gone, registry updated
1017
- expect(existsSync(`${codexSkillPath}.dormant`), 'after lower: .dormant must exist').toBe(true);
1018
- expect(existsSync(codexSkillPath), 'after lower: original must be gone').toBe(false);
1019
- const syncAfterLower = JSON.parse(readFileSync(syncPath, 'utf8'));
1020
- expect(syncAfterLower.dormantRenamed).toContain('.codex/skills/Foo.md');
1021
- // ── Pass 2: raise — asset back in activeAssets; fresh .md pre-placed to simulate write loop
1022
- vi.resetModules();
1023
- const savedRoot = e2eRoot; // preserve before setupRaiseMocks might reset
1024
- await setupRaiseMocks({
1025
- activeAssets: [{
1026
- entryId: 'SETUP-SKILL-FOO',
1027
- name: 'Foo',
1028
- description: 'A skill',
1029
- body: '# Foo\nSkill body.',
1030
- triggers: [],
1031
- assetKind: 'skill',
1032
- semanticRefs: [],
1033
- disabledByOwner: false,
1034
- lastProjectedHash: null,
1035
- scope: 'personal',
1036
- }],
1037
- dormantAssets: [],
1038
- });
1039
- // setupRaiseMocks creates a new e2eRoot — restore the saved root so we test
1040
- // against the lowered FS state from pass 1.
1041
- rmSync(e2eRoot, { recursive: true, force: true });
1042
- e2eRoot = savedRoot;
1043
- cwdSpy.mockReturnValue(e2eRoot);
1044
- // Simulate what the write loop does: re-project the surface fresh.
1045
- // (The codex skill loop doesn't write .codex/skills/*.md in test env because
1046
- // readCanonicalSkills returns []; pre-placing it models the real write-loop output.)
1047
- writeFileSync(codexSkillPath, '<!-- auto-generated by pb handshake -->\n# Foo\nSkill body.\n');
1048
- const { runHandshake: runRaise } = await import('../commands/handshake.js');
1049
- await runRaise({ apply: true });
1050
- // ── .md exists, .dormant gone
1051
- expect(existsSync(codexSkillPath), 'after raise: .codex/skills/Foo.md must exist').toBe(true);
1052
- expect(existsSync(`${codexSkillPath}.dormant`), 'after raise: .dormant must be gone').toBe(false);
1053
- // ── dormantRenamed pruned
1054
- const syncAfterRaise = JSON.parse(readFileSync(syncPath, 'utf8'));
1055
- expect(syncAfterRaise.dormantRenamed ?? [], 'dormantRenamed must not contain the raised path').not.toContain('.codex/skills/Foo.md');
1056
- expect(syncAfterRaise.dormantReactivated ?? [], 'dormantReactivated must not contain the raised path').not.toContain('.codex/skills/Foo.md');
1057
- });
1058
- it('E4: raise with edited .dormant — .dormant preserved and drift TEN queued', async () => {
1059
- // Pre-place state: fresh .md + edited .dormant (bodies differ) → orphan-drift.
1060
- await setupRaiseMocks({
1061
- activeAssets: [{
1062
- entryId: 'SETUP-SKILL-FOO',
1063
- name: 'Foo',
1064
- description: 'A skill',
1065
- body: '# Foo\nSkill body.',
1066
- triggers: [],
1067
- assetKind: 'skill',
1068
- semanticRefs: [],
1069
- disabledByOwner: false,
1070
- lastProjectedHash: null,
1071
- scope: 'personal',
1072
- }],
1073
- dormantAssets: [],
1074
- });
1075
- const codexSkillPath = join(e2eRoot, '.codex', 'skills', 'Foo.md');
1076
- const syncPath = join(e2eRoot, '.productbrain', '.authoring-sync.json');
1077
- // Fresh .md (simulates write loop output)
1078
- writeFileSync(codexSkillPath, '<!-- auto-generated by pb handshake -->\n# Foo\nSkill body.\n');
1079
- // Edited .dormant — body differs from fresh → restoreSurfaceFromDormant returns 'orphan-drift'
1080
- writeFileSync(`${codexSkillPath}.dormant`, '<!-- auto-generated by pb handshake -->\n# Foo\nUSER EDITED CONTENT HERE.\n<!-- pb-status: dormant -->\n');
1081
- // Registry shows this path was previously lowered
1082
- writeFileSync(syncPath, JSON.stringify({
1083
- version: 1, assets: {}, dormantRenamed: ['.codex/skills/Foo.md'], dormantReactivated: [],
1084
- }, null, 2) + '\n');
1085
- const { runHandshake } = await import('../commands/handshake.js');
1086
- await runHandshake({ apply: true });
1087
- // .md still exists (write loop placed it; raise-cleanup doesn't remove it)
1088
- expect(existsSync(codexSkillPath), 'edited variant: .md must exist').toBe(true);
1089
- // .dormant preserved (body differs → orphan-drift)
1090
- expect(existsSync(`${codexSkillPath}.dormant`), 'edited variant: edited .dormant must be preserved').toBe(true);
1091
- // drift TEN queued
1092
- const driftCalls = kernelCallWithSessionMock.mock.calls.filter((call) => {
1093
- const args = call[1];
1094
- return (call[0] === 'chain.createEntry' &&
1095
- args.collectionSlug === 'tensions' &&
1096
- typeof args.data?.description === 'string' &&
1097
- args.data.description.includes('edited while dormant'));
1098
- });
1099
- expect(driftCalls.length, 'edited variant: drift TEN must be queued').toBeGreaterThan(0);
1100
- });
1101
- it('E4: raise also prunes dormantReactivated registry (carry-over obligation)', async () => {
1102
- // Pre-place fresh .md + matching .dormant (bodies match) + path in BOTH registries.
1103
- // After raise: .dormant removed, BOTH entries pruned.
1104
- await setupRaiseMocks({
1105
- activeAssets: [{
1106
- entryId: 'SETUP-SKILL-FOO',
1107
- name: 'Foo',
1108
- description: 'A skill',
1109
- body: '# Foo\nSkill body.',
1110
- triggers: [],
1111
- assetKind: 'skill',
1112
- semanticRefs: [],
1113
- disabledByOwner: false,
1114
- lastProjectedHash: null,
1115
- scope: 'personal',
1116
- }],
1117
- dormantAssets: [],
1118
- });
1119
- const codexSkillPath = join(e2eRoot, '.codex', 'skills', 'Foo.md');
1120
- const syncPath = join(e2eRoot, '.productbrain', '.authoring-sync.json');
1121
- // Fresh .md (simulates write loop output)
1122
- writeFileSync(codexSkillPath, '<!-- auto-generated by pb handshake -->\n# Foo\nSkill body.\n');
1123
- // Matching .dormant (bodies match after stripping DORMANT_MARKER)
1124
- writeFileSync(`${codexSkillPath}.dormant`, '<!-- auto-generated by pb handshake -->\n# Foo\nSkill body.\n<!-- pb-status: dormant -->\n');
1125
- // Registry: path in BOTH dormantRenamed AND dormantReactivated
1126
- writeFileSync(syncPath, JSON.stringify({
1127
- version: 1, assets: {},
1128
- dormantRenamed: ['.codex/skills/Foo.md'],
1129
- dormantReactivated: ['.codex/skills/Foo.md'],
1130
- }, null, 2) + '\n');
1131
- const { runHandshake } = await import('../commands/handshake.js');
1132
- await runHandshake({ apply: true });
1133
- // .dormant removed (bodies match)
1134
- expect(existsSync(`${codexSkillPath}.dormant`), 'carry-over: .dormant must be removed on raise').toBe(false);
1135
- // BOTH registries pruned
1136
- const syncAfterRaise = JSON.parse(readFileSync(syncPath, 'utf8'));
1137
- expect(syncAfterRaise.dormantRenamed ?? [], 'carry-over: dormantRenamed must be pruned').not.toContain('.codex/skills/Foo.md');
1138
- expect(syncAfterRaise.dormantReactivated ?? [], 'carry-over: dormantReactivated must be pruned').not.toContain('.codex/skills/Foo.md');
1139
- });
1140
- // Codex P1: registry pruning must happen for ANY active asset on raise — even when no
1141
- // .dormant sibling exists this run. A surface that was manually reactivated earlier leaves
1142
- // no .dormant (restoreSurfaceFromDormant → 'skipped'), but its path lingers in
1143
- // dormantReactivated. The old code only pruned on 'restored', so the hands-off entry was
1144
- // permanent and a future lowering could never re-dormant the surface. The fix prunes
1145
- // unconditionally for active assets.
1146
- it('E4 (Codex P1): raise prunes dormantReactivated even when no .dormant sibling exists', async () => {
1147
- await setupRaiseMocks({
1148
- activeAssets: [{
1149
- entryId: 'SETUP-RULE-FOO',
1150
- name: 'Foo',
1151
- description: 'An active rule that was manually reactivated earlier (no .dormant left)',
1152
- body: '# Foo\nRule body.',
1153
- triggers: [],
1154
- assetKind: 'rule',
1155
- semanticRefs: [],
1156
- disabledByOwner: false,
1157
- lastProjectedHash: null,
1158
- scope: 'personal',
1159
- }],
1160
- dormantAssets: [],
1161
- });
1162
- const cursorRulePath = join(e2eRoot, '.cursor', 'rules', 'Foo.mdc');
1163
- const claudeRulePath = join(e2eRoot, '.claude', 'rules', 'Foo.md');
1164
- const syncPath = join(e2eRoot, '.productbrain', '.authoring-sync.json');
1165
- // Live files present (asset is active + re-projected), but NO .dormant siblings:
1166
- // the user manually reactivated earlier, so restoreSurfaceFromDormant returns 'skipped'.
1167
- writeFileSync(cursorRulePath, '<!-- auto-generated by pb handshake -->\n# Foo\nRule body.\n');
1168
- writeFileSync(claudeRulePath, '<!-- auto-generated by pb handshake -->\n# Foo\nRule body.\n');
1169
- expect(existsSync(`${cursorRulePath}.dormant`), 'setup: cursor .dormant must be absent').toBe(false);
1170
- expect(existsSync(`${claudeRulePath}.dormant`), 'setup: claude .dormant must be absent').toBe(false);
1171
- // Registry still lists both paths in the hands-off set from the earlier reactivation.
1172
- writeFileSync(syncPath, JSON.stringify({
1173
- version: 1, assets: {},
1174
- dormantRenamed: [],
1175
- dormantReactivated: ['.cursor/rules/Foo.mdc', '.claude/rules/Foo.md'],
1176
- }, null, 2) + '\n');
1177
- const { runHandshake } = await import('../commands/handshake.js');
1178
- await runHandshake({ apply: true });
1179
- // The hands-off entries must be pruned even though no .dormant existed this run,
1180
- // so a future lowering can re-dormant the surface again.
1181
- const syncAfterRaise = JSON.parse(readFileSync(syncPath, 'utf8'));
1182
- expect(syncAfterRaise.dormantReactivated ?? [], 'cursor path must be pruned from dormantReactivated').not.toContain('.cursor/rules/Foo.mdc');
1183
- expect(syncAfterRaise.dormantReactivated ?? [], 'claude path must be pruned from dormantReactivated').not.toContain('.claude/rules/Foo.md');
1184
- });
1185
- // WP-426 E4 Domain Expert fix: surface-filtered raise must NOT false-flag orphan-drift
1186
- //
1187
- // Simulates a raise where the cursor surface was NOT written this run (e.g. --surfaces codex).
1188
- // The cursor .dormant file exists from a prior lowering, but the cursor live .md was never
1189
- // written back by the write loop. The raise-cleanup pass must NOT treat the absent live file
1190
- // as evidence of a user edit → must NOT queue an 'edited while dormant' drift TEN.
1191
- //
1192
- // Implementation: we do NOT pre-place the cursor live file, simulating a surface-filtered run
1193
- // where the cursor loop was skipped. The codex surface IS written (pre-placed) so a full-surface
1194
- // raise would succeed for codex; only cursor is left absent.
1195
- it('E4: surface-filtered raise — absent live file skips, no false orphan-drift TEN, .dormant preserved', async () => {
1196
- await setupRaiseMocks({
1197
- activeAssets: [{
1198
- entryId: 'SETUP-RULE-FOO',
1199
- name: 'Foo',
1200
- description: 'A rule that is active but cursor surface was not written this run',
1201
- body: '# Foo\nRule body.',
1202
- triggers: [],
1203
- assetKind: 'rule',
1204
- semanticRefs: [],
1205
- disabledByOwner: false,
1206
- lastProjectedHash: null,
1207
- scope: 'personal',
1208
- }],
1209
- dormantAssets: [],
1210
- });
1211
- // Cursor rule path — corresponds to deriveDormantFilePaths for assetKind:'rule'
1212
- const cursorRulePath = join(e2eRoot, '.cursor', 'rules', 'Foo.mdc');
1213
- const claudeRulePath = join(e2eRoot, '.claude', 'rules', 'Foo.md');
1214
- const syncPath = join(e2eRoot, '.productbrain', '.authoring-sync.json');
1215
- // ── Pre-place state simulating a prior lowering that created .dormant files
1216
- // for both cursor and claude, then a surface-filtered raise where neither
1217
- // live file was written back by the write loop (both are absent).
1218
- writeFileSync(`${cursorRulePath}.dormant`, '<!-- auto-generated by pb handshake -->\n# Foo\nRule body.\n<!-- pb-status: dormant -->\n');
1219
- writeFileSync(`${claudeRulePath}.dormant`, '<!-- auto-generated by pb handshake -->\n# Foo\nRule body.\n<!-- pb-status: dormant -->\n');
1220
- // Registry shows both paths were previously lowered
1221
- writeFileSync(syncPath, JSON.stringify({
1222
- version: 1, assets: {},
1223
- dormantRenamed: ['.cursor/rules/Foo.mdc', '.claude/rules/Foo.md'],
1224
- dormantReactivated: [],
1225
- }, null, 2) + '\n');
1226
- // Crucially: neither cursorRulePath nor claudeRulePath is pre-placed (surface-filtered run)
1227
- expect(existsSync(cursorRulePath), 'setup: cursor live file must be absent').toBe(false);
1228
- expect(existsSync(claudeRulePath), 'setup: claude live file must be absent').toBe(false);
1229
- const { runHandshake } = await import('../commands/handshake.js');
1230
- await runHandshake({ apply: true });
1231
- // ── (a) .dormant files must still be present (not cleaned up for absent live files)
1232
- expect(existsSync(`${cursorRulePath}.dormant`), 'surface-filtered: cursor .dormant must be preserved').toBe(true);
1233
- expect(existsSync(`${claudeRulePath}.dormant`), 'surface-filtered: claude .dormant must be preserved').toBe(true);
1234
- // ── (b) NO 'edited while dormant' drift TEN must have been queued
1235
- const driftCalls = kernelCallWithSessionMock.mock.calls.filter((call) => {
1236
- const args = call[1];
1237
- return (call[0] === 'chain.createEntry' &&
1238
- args.collectionSlug === 'tensions' &&
1239
- typeof args.data?.description === 'string' &&
1240
- args.data.description.includes('edited while dormant'));
1241
- });
1242
- expect(driftCalls.length, 'surface-filtered: no false orphan-drift TEN must be queued').toBe(0);
1243
- // ── (c) no orphan-drift for the absent cursor / claude surface paths
1244
- // (verified by the absence of drift TEN calls above; belt-and-suspenders check)
1245
- expect(existsSync(cursorRulePath), 'surface-filtered: cursor live file must remain absent').toBe(false);
1246
- expect(existsSync(claudeRulePath), 'surface-filtered: claude live file must remain absent').toBe(false);
1247
- // ── (d) Codex P2: because the .dormant files still exist (restore skipped, no fresh
1248
- // live file), the dormantRenamed registry evidence must be KEPT — not pruned — so a
1249
- // later manual .dormant→live reactivation is still recognized as previouslyRenamed.
1250
- const syncAfterRaise = JSON.parse(readFileSync(syncPath, 'utf8'));
1251
- expect(syncAfterRaise.dormantRenamed ?? [], 'surface-filtered: cursor registry evidence must be kept').toContain('.cursor/rules/Foo.mdc');
1252
- expect(syncAfterRaise.dormantRenamed ?? [], 'surface-filtered: claude registry evidence must be kept').toContain('.claude/rules/Foo.md');
1253
- });
1254
- });
1255
487
  //# sourceMappingURL=handshake.e2e.test.js.map