@productbrain/cli 0.1.0-beta.96 → 0.1.0-beta.978
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.
- package/dist/__tests__/audit.test.js +5 -0
- package/dist/__tests__/audit.test.js.map +1 -1
- package/dist/__tests__/authority-domains.test.js +3 -0
- package/dist/__tests__/authority-domains.test.js.map +1 -1
- package/dist/__tests__/canonicalRefs.vocab.test.d.ts +2 -0
- package/dist/__tests__/canonicalRefs.vocab.test.d.ts.map +1 -0
- package/dist/__tests__/canonicalRefs.vocab.test.js +251 -0
- package/dist/__tests__/canonicalRefs.vocab.test.js.map +1 -0
- package/dist/__tests__/config.test.js +272 -2
- package/dist/__tests__/config.test.js.map +1 -1
- package/dist/__tests__/constants.test.js +6 -1
- package/dist/__tests__/constants.test.js.map +1 -1
- package/dist/__tests__/envelope-contract.test.js +29 -3
- package/dist/__tests__/envelope-contract.test.js.map +1 -1
- package/dist/__tests__/errors.test.js +1 -0
- package/dist/__tests__/errors.test.js.map +1 -1
- package/dist/__tests__/handshake-augment.test.d.ts +2 -0
- package/dist/__tests__/handshake-augment.test.d.ts.map +1 -0
- package/dist/__tests__/handshake-augment.test.js +423 -0
- package/dist/__tests__/handshake-augment.test.js.map +1 -0
- package/dist/__tests__/handshake-dormancy.test.d.ts +2 -0
- package/dist/__tests__/handshake-dormancy.test.d.ts.map +1 -0
- package/dist/__tests__/handshake-dormancy.test.js +207 -0
- package/dist/__tests__/handshake-dormancy.test.js.map +1 -0
- package/dist/__tests__/handshake-formatter.test.d.ts +2 -0
- package/dist/__tests__/handshake-formatter.test.d.ts.map +1 -0
- package/dist/__tests__/handshake-formatter.test.js +67 -0
- package/dist/__tests__/handshake-formatter.test.js.map +1 -0
- package/dist/__tests__/handshake-preview.test.js +566 -4
- package/dist/__tests__/handshake-preview.test.js.map +1 -1
- package/dist/__tests__/handshake.e2e.test.d.ts +2 -0
- package/dist/__tests__/handshake.e2e.test.d.ts.map +1 -0
- package/dist/__tests__/handshake.e2e.test.js +1252 -0
- package/dist/__tests__/handshake.e2e.test.js.map +1 -0
- package/dist/__tests__/handshake.test.js +611 -2
- package/dist/__tests__/handshake.test.js.map +1 -1
- package/dist/__tests__/manifest.test.js +118 -1
- package/dist/__tests__/manifest.test.js.map +1 -1
- package/dist/__tests__/notice-marker.test.d.ts +2 -0
- package/dist/__tests__/notice-marker.test.d.ts.map +1 -0
- package/dist/__tests__/notice-marker.test.js +41 -0
- package/dist/__tests__/notice-marker.test.js.map +1 -0
- package/dist/__tests__/onboarding-path-b.test.js +4 -4
- package/dist/__tests__/onboarding-path-b.test.js.map +1 -1
- package/dist/__tests__/orient.test.js +184 -7
- package/dist/__tests__/orient.test.js.map +1 -1
- package/dist/__tests__/perimeter.test.d.ts +2 -0
- package/dist/__tests__/perimeter.test.d.ts.map +1 -0
- package/dist/__tests__/perimeter.test.js +165 -0
- package/dist/__tests__/perimeter.test.js.map +1 -0
- package/dist/__tests__/personal-layer.test.d.ts +1 -2
- package/dist/__tests__/personal-layer.test.d.ts.map +1 -1
- package/dist/__tests__/personal-layer.test.js +12 -48
- package/dist/__tests__/personal-layer.test.js.map +1 -1
- package/dist/__tests__/profiles.test.js +122 -7
- package/dist/__tests__/profiles.test.js.map +1 -1
- package/dist/__tests__/promote.test.js +2 -2
- package/dist/__tests__/promote.test.js.map +1 -1
- package/dist/__tests__/session-state-machine.test.js +45 -1
- package/dist/__tests__/session-state-machine.test.js.map +1 -1
- package/dist/__tests__/session-switch.test.d.ts +2 -0
- package/dist/__tests__/session-switch.test.d.ts.map +1 -0
- package/dist/__tests__/session-switch.test.js +129 -0
- package/dist/__tests__/session-switch.test.js.map +1 -0
- package/dist/__tests__/setup-ingest.test.js +16 -0
- package/dist/__tests__/setup-ingest.test.js.map +1 -1
- package/dist/__tests__/skill-vocabulary.test.d.ts +21 -0
- package/dist/__tests__/skill-vocabulary.test.d.ts.map +1 -0
- package/dist/__tests__/skill-vocabulary.test.js +187 -0
- package/dist/__tests__/skill-vocabulary.test.js.map +1 -0
- package/dist/__tests__/update-check.test.d.ts +2 -0
- package/dist/__tests__/update-check.test.d.ts.map +1 -0
- package/dist/__tests__/update-check.test.js +215 -0
- package/dist/__tests__/update-check.test.js.map +1 -0
- package/dist/__tests__/upgrade-runner.test.d.ts +2 -0
- package/dist/__tests__/upgrade-runner.test.d.ts.map +1 -0
- package/dist/__tests__/upgrade-runner.test.js +54 -0
- package/dist/__tests__/upgrade-runner.test.js.map +1 -0
- package/dist/__tests__/upgrade.test.d.ts +2 -0
- package/dist/__tests__/upgrade.test.d.ts.map +1 -0
- package/dist/__tests__/upgrade.test.js +56 -0
- package/dist/__tests__/upgrade.test.js.map +1 -0
- package/dist/__tests__/vocabulary-leak.test.d.ts +39 -0
- package/dist/__tests__/vocabulary-leak.test.d.ts.map +1 -0
- package/dist/__tests__/vocabulary-leak.test.js +534 -0
- package/dist/__tests__/vocabulary-leak.test.js.map +1 -0
- package/dist/__tests__/workspace.test.js +32 -12
- package/dist/__tests__/workspace.test.js.map +1 -1
- package/dist/commands/__tests__/connect-handoff.test.d.ts +11 -0
- package/dist/commands/__tests__/connect-handoff.test.d.ts.map +1 -0
- package/dist/commands/__tests__/connect-handoff.test.js +111 -0
- package/dist/commands/__tests__/connect-handoff.test.js.map +1 -0
- package/dist/commands/__tests__/setup-detect-surfaces.test.d.ts +15 -0
- package/dist/commands/__tests__/setup-detect-surfaces.test.d.ts.map +1 -0
- package/dist/commands/__tests__/setup-detect-surfaces.test.js +149 -0
- package/dist/commands/__tests__/setup-detect-surfaces.test.js.map +1 -0
- package/dist/commands/__tests__/setup-state.test.d.ts +2 -0
- package/dist/commands/__tests__/setup-state.test.d.ts.map +1 -0
- package/dist/commands/__tests__/setup-state.test.js +194 -0
- package/dist/commands/__tests__/setup-state.test.js.map +1 -0
- package/dist/commands/admin/seed.d.ts +46 -2
- package/dist/commands/admin/seed.d.ts.map +1 -1
- package/dist/commands/admin/seed.js +475 -33
- package/dist/commands/admin/seed.js.map +1 -1
- package/dist/commands/admin/seed.test.d.ts +5 -0
- package/dist/commands/admin/seed.test.d.ts.map +1 -1
- package/dist/commands/admin/seed.test.js +67 -2
- package/dist/commands/admin/seed.test.js.map +1 -1
- package/dist/commands/admin/seedRegistryEntries.generated.d.ts +14 -0
- package/dist/commands/admin/seedRegistryEntries.generated.d.ts.map +1 -0
- package/dist/commands/admin/seedRegistryEntries.generated.js +117 -0
- package/dist/commands/admin/seedRegistryEntries.generated.js.map +1 -0
- package/dist/commands/admin/seedRegistryEntries.test.d.ts +11 -0
- package/dist/commands/admin/seedRegistryEntries.test.d.ts.map +1 -0
- package/dist/commands/admin/seedRegistryEntries.test.js +67 -0
- package/dist/commands/admin/seedRegistryEntries.test.js.map +1 -0
- package/dist/commands/audit.d.ts.map +1 -1
- package/dist/commands/audit.js +30 -3
- package/dist/commands/audit.js.map +1 -1
- package/dist/commands/authority-domains.d.ts +25 -1
- package/dist/commands/authority-domains.d.ts.map +1 -1
- package/dist/commands/authority-domains.js +51 -4
- package/dist/commands/authority-domains.js.map +1 -1
- package/dist/commands/capture.d.ts.map +1 -1
- package/dist/commands/capture.js +3 -2
- package/dist/commands/capture.js.map +1 -1
- package/dist/commands/codex-prep.d.ts +1 -0
- package/dist/commands/codex-prep.d.ts.map +1 -1
- package/dist/commands/codex-prep.js +10 -7
- package/dist/commands/codex-prep.js.map +1 -1
- package/dist/commands/connect-config.test.d.ts +2 -0
- package/dist/commands/connect-config.test.d.ts.map +1 -0
- package/dist/commands/connect-config.test.js +44 -0
- package/dist/commands/connect-config.test.js.map +1 -0
- package/dist/commands/connect-context.d.ts +45 -0
- package/dist/commands/connect-context.d.ts.map +1 -0
- package/dist/commands/connect-context.js +64 -0
- package/dist/commands/connect-context.js.map +1 -0
- package/dist/commands/connect-context.test.d.ts +2 -0
- package/dist/commands/connect-context.test.d.ts.map +1 -0
- package/dist/commands/connect-context.test.js +110 -0
- package/dist/commands/connect-context.test.js.map +1 -0
- package/dist/commands/connect-handoff.d.ts +51 -0
- package/dist/commands/connect-handoff.d.ts.map +1 -0
- package/dist/commands/connect-handoff.js +70 -0
- package/dist/commands/connect-handoff.js.map +1 -0
- package/dist/commands/connect-integration.test.js +29 -12
- package/dist/commands/connect-integration.test.js.map +1 -1
- package/dist/commands/connect-screens.d.ts +6 -4
- package/dist/commands/connect-screens.d.ts.map +1 -1
- package/dist/commands/connect-screens.js +30 -19
- package/dist/commands/connect-screens.js.map +1 -1
- package/dist/commands/connect.d.ts +21 -6
- package/dist/commands/connect.d.ts.map +1 -1
- package/dist/commands/connect.js +65 -58
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/connect.test.js +17 -1
- package/dist/commands/connect.test.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +68 -3
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +150 -0
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/commands/handshake.d.ts +194 -2
- package/dist/commands/handshake.d.ts.map +1 -1
- package/dist/commands/handshake.js +1537 -46
- package/dist/commands/handshake.js.map +1 -1
- package/dist/commands/method.d.ts.map +1 -1
- package/dist/commands/method.js +3 -0
- package/dist/commands/method.js.map +1 -1
- package/dist/commands/orient.d.ts +63 -2
- package/dist/commands/orient.d.ts.map +1 -1
- package/dist/commands/orient.js +106 -4
- package/dist/commands/orient.js.map +1 -1
- package/dist/commands/profile.d.ts +1 -14
- package/dist/commands/profile.d.ts.map +1 -1
- package/dist/commands/profile.js +89 -72
- package/dist/commands/profile.js.map +1 -1
- package/dist/commands/promote.js +1 -1
- package/dist/commands/promote.js.map +1 -1
- package/dist/commands/relate.d.ts.map +1 -1
- package/dist/commands/relate.js +13 -0
- package/dist/commands/relate.js.map +1 -1
- package/dist/commands/session.d.ts.map +1 -1
- package/dist/commands/session.js +51 -14
- package/dist/commands/session.js.map +1 -1
- package/dist/commands/setup-audit.d.ts +59 -0
- package/dist/commands/setup-audit.d.ts.map +1 -0
- package/dist/commands/setup-audit.js +250 -0
- package/dist/commands/setup-audit.js.map +1 -0
- package/dist/commands/setup-detect-surfaces.d.ts +38 -0
- package/dist/commands/setup-detect-surfaces.d.ts.map +1 -0
- package/dist/commands/setup-detect-surfaces.js +76 -0
- package/dist/commands/setup-detect-surfaces.js.map +1 -0
- package/dist/commands/setup-ingest.d.ts.map +1 -1
- package/dist/commands/setup-ingest.js +4 -2
- package/dist/commands/setup-ingest.js.map +1 -1
- package/dist/commands/setup-state.d.ts +42 -0
- package/dist/commands/setup-state.d.ts.map +1 -0
- package/dist/commands/setup-state.js +93 -0
- package/dist/commands/setup-state.js.map +1 -0
- package/dist/commands/setup.d.ts +17 -9
- package/dist/commands/setup.d.ts.map +1 -1
- package/dist/commands/setup.js +52 -131
- package/dist/commands/setup.js.map +1 -1
- package/dist/commands/upgrade.d.ts +5 -0
- package/dist/commands/upgrade.d.ts.map +1 -0
- package/dist/commands/upgrade.js +110 -0
- package/dist/commands/upgrade.js.map +1 -0
- package/dist/commands/whoami.d.ts +12 -0
- package/dist/commands/whoami.d.ts.map +1 -0
- package/dist/commands/whoami.js +70 -0
- package/dist/commands/whoami.js.map +1 -0
- package/dist/commands/whoami.test.d.ts +2 -0
- package/dist/commands/whoami.test.d.ts.map +1 -0
- package/dist/commands/whoami.test.js +50 -0
- package/dist/commands/whoami.test.js.map +1 -0
- package/dist/commands/workspace.d.ts +23 -2
- package/dist/commands/workspace.d.ts.map +1 -1
- package/dist/commands/workspace.js +2 -2
- package/dist/commands/workspace.js.map +1 -1
- package/dist/formatters/__tests__/orient-provenance.test.d.ts +7 -0
- package/dist/formatters/__tests__/orient-provenance.test.d.ts.map +1 -0
- package/dist/formatters/__tests__/orient-provenance.test.js +454 -0
- package/dist/formatters/__tests__/orient-provenance.test.js.map +1 -0
- package/dist/formatters/audit.d.ts +6 -0
- package/dist/formatters/audit.d.ts.map +1 -1
- package/dist/formatters/audit.js.map +1 -1
- package/dist/formatters/entry.d.ts +21 -0
- package/dist/formatters/entry.d.ts.map +1 -1
- package/dist/formatters/entry.js +46 -5
- package/dist/formatters/entry.js.map +1 -1
- package/dist/formatters/handshake.d.ts +19 -3
- package/dist/formatters/handshake.d.ts.map +1 -1
- package/dist/formatters/handshake.js +48 -13
- package/dist/formatters/handshake.js.map +1 -1
- package/dist/formatters/orient.d.ts +79 -4
- package/dist/formatters/orient.d.ts.map +1 -1
- package/dist/formatters/orient.js +119 -18
- package/dist/formatters/orient.js.map +1 -1
- package/dist/formatters/session.js +1 -1
- package/dist/formatters/session.js.map +1 -1
- package/dist/generators/adapters.js +2 -2
- package/dist/generators/context-md.js +6 -6
- package/dist/generators/context-md.js.map +1 -1
- package/dist/generators/manifest.d.ts +76 -0
- package/dist/generators/manifest.d.ts.map +1 -1
- package/dist/generators/manifest.js +125 -14
- package/dist/generators/manifest.js.map +1 -1
- package/dist/generators/portable-knowledge.d.ts +6 -12
- package/dist/generators/portable-knowledge.d.ts.map +1 -1
- package/dist/generators/portable-knowledge.js +2 -19
- package/dist/generators/portable-knowledge.js.map +1 -1
- package/dist/generators/region-projections.d.ts +18 -0
- package/dist/generators/region-projections.d.ts.map +1 -0
- package/dist/generators/region-projections.js +49 -0
- package/dist/generators/region-projections.js.map +1 -0
- package/dist/generators/region-projections.test.d.ts +2 -0
- package/dist/generators/region-projections.test.d.ts.map +1 -0
- package/dist/generators/region-projections.test.js +63 -0
- package/dist/generators/region-projections.test.js.map +1 -0
- package/dist/generators/region.d.ts +24 -0
- package/dist/generators/region.d.ts.map +1 -0
- package/dist/generators/region.js +87 -0
- package/dist/generators/region.js.map +1 -0
- package/dist/generators/region.test.d.ts +2 -0
- package/dist/generators/region.test.d.ts.map +1 -0
- package/dist/generators/region.test.js +126 -0
- package/dist/generators/region.test.js.map +1 -0
- package/dist/generators/surface-profiles.d.ts +1 -2
- package/dist/generators/surface-profiles.d.ts.map +1 -1
- package/dist/generators/surface-profiles.js.map +1 -1
- package/dist/index.js +142 -27
- package/dist/index.js.map +1 -1
- package/dist/lib/activation.d.ts.map +1 -1
- package/dist/lib/activation.js +3 -3
- package/dist/lib/activation.js.map +1 -1
- package/dist/lib/activation.test.js +3 -3
- package/dist/lib/activation.test.js.map +1 -1
- package/dist/lib/canonicalRefs.d.ts +72 -0
- package/dist/lib/canonicalRefs.d.ts.map +1 -1
- package/dist/lib/canonicalRefs.js +67 -0
- package/dist/lib/canonicalRefs.js.map +1 -1
- package/dist/lib/client.d.ts.map +1 -1
- package/dist/lib/client.js +14 -4
- package/dist/lib/client.js.map +1 -1
- package/dist/lib/config.d.ts +70 -4
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +151 -11
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/connectKeyLabel.d.ts +9 -0
- package/dist/lib/connectKeyLabel.d.ts.map +1 -0
- package/dist/lib/connectKeyLabel.js +12 -0
- package/dist/lib/connectKeyLabel.js.map +1 -0
- package/dist/lib/constants.d.ts +2 -0
- package/dist/lib/constants.d.ts.map +1 -1
- package/dist/lib/constants.js +2 -0
- package/dist/lib/constants.js.map +1 -1
- package/dist/lib/errors.d.ts +3 -0
- package/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js +3 -0
- package/dist/lib/errors.js.map +1 -1
- package/dist/lib/normalizeMaterializedFilename.d.ts +28 -0
- package/dist/lib/normalizeMaterializedFilename.d.ts.map +1 -0
- package/dist/lib/normalizeMaterializedFilename.js +56 -0
- package/dist/lib/normalizeMaterializedFilename.js.map +1 -0
- package/dist/lib/normalizeMaterializedFilename.test.d.ts +16 -0
- package/dist/lib/normalizeMaterializedFilename.test.d.ts.map +1 -0
- package/dist/lib/normalizeMaterializedFilename.test.js +90 -0
- package/dist/lib/normalizeMaterializedFilename.test.js.map +1 -0
- package/dist/lib/notice-marker.d.ts +3 -0
- package/dist/lib/notice-marker.d.ts.map +1 -0
- package/dist/lib/notice-marker.js +53 -0
- package/dist/lib/notice-marker.js.map +1 -0
- package/dist/lib/onboarding-path-b.d.ts.map +1 -1
- package/dist/lib/onboarding-path-b.js +0 -1
- package/dist/lib/onboarding-path-b.js.map +1 -1
- package/dist/lib/onboarding-shared.d.ts +0 -1
- package/dist/lib/onboarding-shared.d.ts.map +1 -1
- package/dist/lib/onboarding-shared.js +1 -17
- package/dist/lib/onboarding-shared.js.map +1 -1
- package/dist/lib/profiles.d.ts +3 -1
- package/dist/lib/profiles.d.ts.map +1 -1
- package/dist/lib/profiles.js +9 -6
- package/dist/lib/profiles.js.map +1 -1
- package/dist/lib/session.d.ts +10 -0
- package/dist/lib/session.d.ts.map +1 -1
- package/dist/lib/session.js +14 -0
- package/dist/lib/session.js.map +1 -1
- package/dist/lib/update-check.d.ts +42 -7
- package/dist/lib/update-check.d.ts.map +1 -1
- package/dist/lib/update-check.js +213 -62
- package/dist/lib/update-check.js.map +1 -1
- package/dist/lib/upgrade-runner.d.ts +22 -0
- package/dist/lib/upgrade-runner.d.ts.map +1 -0
- package/dist/lib/upgrade-runner.js +110 -0
- package/dist/lib/upgrade-runner.js.map +1 -0
- package/dist/lib/workspaceVocabCache.d.ts +60 -0
- package/dist/lib/workspaceVocabCache.d.ts.map +1 -0
- package/dist/lib/workspaceVocabCache.js +98 -0
- package/dist/lib/workspaceVocabCache.js.map +1 -0
- package/dist/setup/__tests__/coach-traces.test.d.ts +2 -0
- package/dist/setup/__tests__/coach-traces.test.d.ts.map +1 -0
- package/dist/setup/__tests__/coach-traces.test.js +189 -0
- package/dist/setup/__tests__/coach-traces.test.js.map +1 -0
- package/dist/setup/__tests__/setup-commands.test.d.ts +2 -0
- package/dist/setup/__tests__/setup-commands.test.d.ts.map +1 -0
- package/dist/setup/__tests__/setup-commands.test.js +177 -0
- package/dist/setup/__tests__/setup-commands.test.js.map +1 -0
- package/dist/setup/__tests__/state-machine.test.d.ts +2 -0
- package/dist/setup/__tests__/state-machine.test.d.ts.map +1 -0
- package/dist/setup/__tests__/state-machine.test.js +341 -0
- package/dist/setup/__tests__/state-machine.test.js.map +1 -0
- package/dist/setup/detect-surfaces.d.ts +21 -0
- package/dist/setup/detect-surfaces.d.ts.map +1 -0
- package/dist/setup/detect-surfaces.js +39 -0
- package/dist/setup/detect-surfaces.js.map +1 -0
- package/dist/setup/manifest-writer.d.ts +17 -0
- package/dist/setup/manifest-writer.d.ts.map +1 -0
- package/dist/setup/manifest-writer.js +153 -0
- package/dist/setup/manifest-writer.js.map +1 -0
- package/dist/setup/perimeter.d.ts +72 -0
- package/dist/setup/perimeter.d.ts.map +1 -0
- package/dist/setup/perimeter.js +128 -0
- package/dist/setup/perimeter.js.map +1 -0
- package/dist/setup/state-machine.d.ts +67 -0
- package/dist/setup/state-machine.d.ts.map +1 -0
- package/dist/setup/state-machine.js +124 -0
- package/dist/setup/state-machine.js.map +1 -0
- package/dist/surfaces/__tests__/adapter.test.d.ts +2 -0
- package/dist/surfaces/__tests__/adapter.test.d.ts.map +1 -0
- package/dist/surfaces/__tests__/adapter.test.js +90 -0
- package/dist/surfaces/__tests__/adapter.test.js.map +1 -0
- package/dist/surfaces/__tests__/pb-setup-passthrough.test.d.ts +2 -0
- package/dist/surfaces/__tests__/pb-setup-passthrough.test.d.ts.map +1 -0
- package/dist/surfaces/__tests__/pb-setup-passthrough.test.js +132 -0
- package/dist/surfaces/__tests__/pb-setup-passthrough.test.js.map +1 -0
- package/dist/surfaces/__tests__/telemetry.test.d.ts +2 -0
- package/dist/surfaces/__tests__/telemetry.test.d.ts.map +1 -0
- package/dist/surfaces/__tests__/telemetry.test.js +55 -0
- package/dist/surfaces/__tests__/telemetry.test.js.map +1 -0
- package/dist/surfaces/adapter.d.ts +70 -0
- package/dist/surfaces/adapter.d.ts.map +1 -0
- package/dist/surfaces/adapter.js +2 -0
- package/dist/surfaces/adapter.js.map +1 -0
- package/dist/surfaces/adapters/claude.d.ts +3 -0
- package/dist/surfaces/adapters/claude.d.ts.map +1 -0
- package/dist/surfaces/adapters/claude.js +67 -0
- package/dist/surfaces/adapters/claude.js.map +1 -0
- package/dist/surfaces/adapters/codex.d.ts +3 -0
- package/dist/surfaces/adapters/codex.d.ts.map +1 -0
- package/dist/surfaces/adapters/codex.js +61 -0
- package/dist/surfaces/adapters/codex.js.map +1 -0
- package/dist/surfaces/adapters/copilot.d.ts +3 -0
- package/dist/surfaces/adapters/copilot.d.ts.map +1 -0
- package/dist/surfaces/adapters/copilot.js +59 -0
- package/dist/surfaces/adapters/copilot.js.map +1 -0
- package/dist/surfaces/adapters/cursor.d.ts +3 -0
- package/dist/surfaces/adapters/cursor.d.ts.map +1 -0
- package/dist/surfaces/adapters/cursor.js +78 -0
- package/dist/surfaces/adapters/cursor.js.map +1 -0
- package/dist/surfaces/registry.d.ts +58 -2
- package/dist/surfaces/registry.d.ts.map +1 -1
- package/dist/surfaces/registry.js +82 -7
- package/dist/surfaces/registry.js.map +1 -1
- package/dist/surfaces/telemetry.d.ts +17 -0
- package/dist/surfaces/telemetry.d.ts.map +1 -0
- package/dist/surfaces/telemetry.js +31 -0
- package/dist/surfaces/telemetry.js.map +1 -0
- package/package.json +3 -1
- package/dist/__tests__/setup.test.d.ts +0 -2
- package/dist/__tests__/setup.test.d.ts.map +0 -1
- package/dist/__tests__/setup.test.js +0 -141
- package/dist/__tests__/setup.test.js.map +0 -1
- package/dist/generators/__tests__/surface-profiles.test.d.ts +0 -2
- package/dist/generators/__tests__/surface-profiles.test.d.ts.map +0 -1
- package/dist/generators/__tests__/surface-profiles.test.js +0 -89
- package/dist/generators/__tests__/surface-profiles.test.js.map +0 -1
- package/dist/lib/onboarding-phases.d.ts +0 -9
- package/dist/lib/onboarding-phases.d.ts.map +0 -1
- package/dist/lib/onboarding-phases.js +0 -120
- package/dist/lib/onboarding-phases.js.map +0 -1
|
@@ -0,0 +1,1252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WP-421 S7 — table-driven E2E for the four authority-mode transitions
|
|
3
|
+
* (doneWhen #27). One shared tempdir fixture exercises four mode-transition
|
|
4
|
+
* rows in a `describe.each` so a regression in any single transition shows up
|
|
5
|
+
* with a row-named failure (e.g. "row 3 / project → govern").
|
|
6
|
+
*
|
|
7
|
+
* Why this stays CLI-side (not full handshake → Convex):
|
|
8
|
+
*
|
|
9
|
+
* The full `runHandshake` round-trips the AKI gateway, listAssetsForUser,
|
|
10
|
+
* and writeSetupReceipt. Those are exercised by the handshake.test.ts mocks
|
|
11
|
+
* and the convex-test integration suite. This file pins the per-transition
|
|
12
|
+
* filesystem contract that every handshake share regardless of mode:
|
|
13
|
+
* - drift report classification (3 buckets) is correct for the fixture,
|
|
14
|
+
* - PB-managed projection paths are inside the perimeter,
|
|
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.
|
|
18
|
+
*
|
|
19
|
+
* The four rows share ONE tempdir (per spec) — failures localize to the
|
|
20
|
+
* `transitionName` column.
|
|
21
|
+
*/
|
|
22
|
+
import { mkdtempSync, rmSync, mkdirSync, readFileSync, writeFileSync, existsSync, renameSync } from 'fs';
|
|
23
|
+
import { tmpdir } from 'os';
|
|
24
|
+
import { join, dirname } from 'path';
|
|
25
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
26
|
+
import { classifyDriftBucket, DORMANT_MARKER, writeDormantMarkerToFile, } from '../commands/handshake.js';
|
|
27
|
+
import { MARKER } from '../generators/adapters.js';
|
|
28
|
+
import { assertSetupWritePath, SETUP_PERIMETER_VIOLATION } from '../setup/perimeter.js';
|
|
29
|
+
import { readManifestStatus } from '../generators/manifest.js';
|
|
30
|
+
import { createHash } from 'crypto';
|
|
31
|
+
// ── Shared tempdir fixture ───────────────────────────────────────────────────
|
|
32
|
+
let sharedRoot;
|
|
33
|
+
let savedCwd;
|
|
34
|
+
const FIXTURE_FILES = {
|
|
35
|
+
// PB-managed projection (clean): has MARKER + matching pb-hash trailer.
|
|
36
|
+
pbManagedClean: '.cursor/rules/pb-orient.mdc',
|
|
37
|
+
// User-edited projection: has MARKER + STALE pb-hash trailer (tampered bucket).
|
|
38
|
+
pbManagedTampered: '.cursor/rules/pb-tampered.mdc',
|
|
39
|
+
// User-owned: no MARKER, never touched by PB.
|
|
40
|
+
userOwned: '.cursor/rules/my-custom.mdc',
|
|
41
|
+
// Manifest at .productbrain/manifest.yaml carries adopted: + a platform-locked entry.
|
|
42
|
+
manifest: '.productbrain/manifest.yaml',
|
|
43
|
+
};
|
|
44
|
+
/** Compute the pb-hash trailer the projection path uses, so the fixture round-trips clean. */
|
|
45
|
+
function buildCleanProjection(body) {
|
|
46
|
+
const HASH_TRAILER_REGEX = /^<!--\s*pb-hash:.*-->\s*$/gm;
|
|
47
|
+
const TIMESTAMP_REGEX = /^<!--\s*pb-generated-at:.*-->\s*$/gm;
|
|
48
|
+
const head = `${MARKER}\n${body}`;
|
|
49
|
+
const normalized = head
|
|
50
|
+
.replace(HASH_TRAILER_REGEX, '')
|
|
51
|
+
.replace(TIMESTAMP_REGEX, '')
|
|
52
|
+
.replace(/\r\n/g, '\n')
|
|
53
|
+
.replace(/\r/g, '\n')
|
|
54
|
+
.trimEnd();
|
|
55
|
+
const hash = createHash('sha256').update(normalized, 'utf8').digest('hex');
|
|
56
|
+
return `${normalized}\n<!-- pb-hash: sha256:${hash} -->`;
|
|
57
|
+
}
|
|
58
|
+
beforeAll(() => {
|
|
59
|
+
sharedRoot = mkdtempSync(join(tmpdir(), 'pb-wp421-s7-e2e-'));
|
|
60
|
+
mkdirSync(join(sharedRoot, '.productbrain'), { recursive: true });
|
|
61
|
+
mkdirSync(join(sharedRoot, '.cursor', 'rules'), { recursive: true });
|
|
62
|
+
mkdirSync(join(sharedRoot, '.claude', 'rules'), { recursive: true });
|
|
63
|
+
// Manifest with adopted: allowlist + platform-locked entry per spec.
|
|
64
|
+
// Note: version must be a string (validator rejects numeric); minimal YAML
|
|
65
|
+
// parser strips quotes so `"0.1"` round-trips as the string "0.1".
|
|
66
|
+
const manifestYaml = [
|
|
67
|
+
'version: "0.1"',
|
|
68
|
+
'materialize: observe',
|
|
69
|
+
'surfaces:',
|
|
70
|
+
' - .cursor',
|
|
71
|
+
' - .claude',
|
|
72
|
+
'adopted:',
|
|
73
|
+
' - pb-orient',
|
|
74
|
+
' - pb-tampered',
|
|
75
|
+
'lock:',
|
|
76
|
+
' SETUP-RULE-PB-ORIENT: platform',
|
|
77
|
+
'',
|
|
78
|
+
].join('\n');
|
|
79
|
+
writeFileSync(join(sharedRoot, FIXTURE_FILES.manifest), manifestYaml);
|
|
80
|
+
// PB-managed clean: MARKER + matching trailer.
|
|
81
|
+
writeFileSync(join(sharedRoot, FIXTURE_FILES.pbManagedClean), buildCleanProjection('# Orient skill\n\nClean body.\n'));
|
|
82
|
+
// PB-managed tampered: MARKER + trailer, but body edited downstream of the trailer.
|
|
83
|
+
const cleanForTamper = buildCleanProjection('# Tampered\n\nOriginal body.\n');
|
|
84
|
+
writeFileSync(join(sharedRoot, FIXTURE_FILES.pbManagedTampered), cleanForTamper.replace('Original body.', 'Original body.\n\nUSER EDITED HERE.'));
|
|
85
|
+
// User-owned: no MARKER. Must be inert under every transition.
|
|
86
|
+
writeFileSync(join(sharedRoot, FIXTURE_FILES.userOwned), '# My custom rule — authored by hand, no marker.\n');
|
|
87
|
+
savedCwd = process.cwd();
|
|
88
|
+
process.chdir(sharedRoot);
|
|
89
|
+
});
|
|
90
|
+
afterAll(() => {
|
|
91
|
+
process.chdir(savedCwd);
|
|
92
|
+
rmSync(sharedRoot, { recursive: true, force: true });
|
|
93
|
+
});
|
|
94
|
+
const ROWS = [
|
|
95
|
+
{
|
|
96
|
+
transitionName: 'bootstrap → observe',
|
|
97
|
+
fromMode: 'off',
|
|
98
|
+
toMode: 'observe',
|
|
99
|
+
writesProjections: false,
|
|
100
|
+
lowersToDormant: false,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
transitionName: 'observe → project',
|
|
104
|
+
fromMode: 'observe',
|
|
105
|
+
toMode: 'project',
|
|
106
|
+
writesProjections: true,
|
|
107
|
+
lowersToDormant: false,
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
transitionName: 'project → govern',
|
|
111
|
+
fromMode: 'project',
|
|
112
|
+
toMode: 'govern',
|
|
113
|
+
writesProjections: true,
|
|
114
|
+
lowersToDormant: false,
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
transitionName: 'govern → off',
|
|
118
|
+
fromMode: 'govern',
|
|
119
|
+
toMode: 'off',
|
|
120
|
+
writesProjections: false,
|
|
121
|
+
lowersToDormant: true,
|
|
122
|
+
},
|
|
123
|
+
];
|
|
124
|
+
// ── Per-row assertions ───────────────────────────────────────────────────────
|
|
125
|
+
describe('WP-421 S7 — table-driven E2E (doneWhen #27): four authority transitions over one shared tempdir', () => {
|
|
126
|
+
describe.each(ROWS)('row: $transitionName (fromMode=$fromMode → toMode=$toMode)', (row) => {
|
|
127
|
+
it('drift report buckets the three fixture files correctly', () => {
|
|
128
|
+
const cleanResult = classifyDriftBucket(join(process.cwd(), FIXTURE_FILES.pbManagedClean));
|
|
129
|
+
const tamperedResult = classifyDriftBucket(join(process.cwd(), FIXTURE_FILES.pbManagedTampered));
|
|
130
|
+
const userOwnedResult = classifyDriftBucket(join(process.cwd(), FIXTURE_FILES.userOwned));
|
|
131
|
+
expect(cleanResult, `${row.transitionName}: clean fixture must classify as pb-managed-clean`).not.toBeNull();
|
|
132
|
+
expect(cleanResult.bucket).toBe('pb-managed-clean');
|
|
133
|
+
expect(tamperedResult, `${row.transitionName}: tampered fixture must classify as pb-managed-tampered`).not.toBeNull();
|
|
134
|
+
expect(tamperedResult.bucket).toBe('pb-managed-tampered');
|
|
135
|
+
expect(tamperedResult.expectedHash).not.toBe(tamperedResult.actualHash);
|
|
136
|
+
expect(userOwnedResult, `${row.transitionName}: user-owned fixture must classify as user-owned`).not.toBeNull();
|
|
137
|
+
expect(userOwnedResult.bucket).toBe('user-owned');
|
|
138
|
+
});
|
|
139
|
+
it('manifest reader returns expected mode + surfaces + lock for the fixture', () => {
|
|
140
|
+
// The manifest itself does not change row-to-row; we only assert the
|
|
141
|
+
// shape that the perimeter consults. (Mode field tracking happens in
|
|
142
|
+
// the convex-test integration suite — there's no need to mutate the
|
|
143
|
+
// file here per row.)
|
|
144
|
+
const status = readManifestStatus(join(process.cwd(), '.productbrain'));
|
|
145
|
+
expect(status.parseStatus).toBe('ok');
|
|
146
|
+
expect(status.surfaces).toEqual(['.cursor', '.claude']);
|
|
147
|
+
expect(status.lock).toEqual({ 'SETUP-RULE-PB-ORIENT': 'platform' });
|
|
148
|
+
// Defense-in-depth: per DEC-963, when parseStatus !== 'ok', mode must
|
|
149
|
+
// never be 'project' or 'govern'. Our fixture is parseable so this is
|
|
150
|
+
// an OK assertion, but it pins the invariant for review.
|
|
151
|
+
if (status.parseStatus !== 'ok') {
|
|
152
|
+
expect(['off', 'observe']).toContain(status.mode);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
it('perimeter accepts writes inside .cursor and .productbrain; rejects writes outside', () => {
|
|
156
|
+
const status = readManifestStatus(join(process.cwd(), '.productbrain'));
|
|
157
|
+
// PB-managed clean is inside .cursor/rules → allowed.
|
|
158
|
+
expect(() => assertSetupWritePath(join(process.cwd(), FIXTURE_FILES.pbManagedClean), status)).not.toThrow();
|
|
159
|
+
// .productbrain/skills/orient.md is hard-coded allowed.
|
|
160
|
+
expect(() => assertSetupWritePath(join(process.cwd(), '.productbrain', 'skills', 'orient.md'), status)).not.toThrow();
|
|
161
|
+
// Anything outside the perimeter (e.g. /tmp/foo or sibling dir) is refused.
|
|
162
|
+
const outside = join(sharedRoot, '..', 'definitely-outside-the-perimeter');
|
|
163
|
+
let caught = null;
|
|
164
|
+
try {
|
|
165
|
+
assertSetupWritePath(outside, status);
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
caught = err;
|
|
169
|
+
}
|
|
170
|
+
expect(caught, `${row.transitionName}: perimeter must refuse outside-perimeter writes`).toBeInstanceOf(Error);
|
|
171
|
+
expect(caught.code).toBe(SETUP_PERIMETER_VIOLATION);
|
|
172
|
+
});
|
|
173
|
+
it('user-owned file is untouched after the transition (no MARKER, never written)', () => {
|
|
174
|
+
// Per row 2 (observe → project): "PB-managed projected, user-owned untouched."
|
|
175
|
+
// We assert the same invariant on every row — user-owned never gains a
|
|
176
|
+
// MARKER, never gains a dormant marker, never gets overwritten.
|
|
177
|
+
const userOwnedPath = join(process.cwd(), FIXTURE_FILES.userOwned);
|
|
178
|
+
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
|
+
const after = readFileSync(userOwnedPath, 'utf8');
|
|
184
|
+
expect(after).toBe(before);
|
|
185
|
+
expect(after.includes(MARKER)).toBe(false);
|
|
186
|
+
expect(after.includes(DORMANT_MARKER)).toBe(false);
|
|
187
|
+
});
|
|
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
|
+
if (row.transitionName === 'project → govern') {
|
|
210
|
+
it('project → govern: lock metadata in manifest survives the transition (.lock honored)', () => {
|
|
211
|
+
// The manifest declares SETUP-RULE-PB-ORIENT: platform. This is the
|
|
212
|
+
// contract the fork-auth check (DEC-961) consults server-side — the
|
|
213
|
+
// CLI only reads the manifest and forwards. We assert the read.
|
|
214
|
+
const status = readManifestStatus(join(process.cwd(), '.productbrain'));
|
|
215
|
+
expect(status.lock['SETUP-RULE-PB-ORIENT']).toBe('platform');
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
if (row.transitionName === 'observe → project') {
|
|
219
|
+
it('observe → project: PB-managed paths resolve inside .cursor (perimeter-approved projection)', () => {
|
|
220
|
+
const status = readManifestStatus(join(process.cwd(), '.productbrain'));
|
|
221
|
+
// The two PB-managed fixture paths must both resolve inside the
|
|
222
|
+
// declared perimeter — that's what makes "observe → project" the
|
|
223
|
+
// transition that's allowed to write at all.
|
|
224
|
+
expect(() => assertSetupWritePath(join(process.cwd(), FIXTURE_FILES.pbManagedClean), status)).not.toThrow();
|
|
225
|
+
expect(() => assertSetupWritePath(join(process.cwd(), FIXTURE_FILES.pbManagedTampered), status)).not.toThrow();
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
if (row.transitionName === 'bootstrap → observe') {
|
|
229
|
+
it('bootstrap → observe: drift report runs without writes (read-only inspection)', () => {
|
|
230
|
+
// The 'observe' mode is read-only: handshake reports drift but never
|
|
231
|
+
// projects. Assert that running classifyDriftBucket on every fixture
|
|
232
|
+
// does not change the file content.
|
|
233
|
+
const before = {};
|
|
234
|
+
for (const [name, rel] of Object.entries(FIXTURE_FILES)) {
|
|
235
|
+
if (name === 'manifest')
|
|
236
|
+
continue;
|
|
237
|
+
before[rel] = readFileSync(join(process.cwd(), rel), 'utf8');
|
|
238
|
+
}
|
|
239
|
+
for (const rel of Object.values(FIXTURE_FILES)) {
|
|
240
|
+
classifyDriftBucket(join(process.cwd(), rel));
|
|
241
|
+
}
|
|
242
|
+
for (const [name, rel] of Object.entries(FIXTURE_FILES)) {
|
|
243
|
+
if (name === 'manifest')
|
|
244
|
+
continue;
|
|
245
|
+
expect(readFileSync(join(process.cwd(), rel), 'utf8')).toBe(before[rel]);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
// ── Cross-row sanity: the shared fixture survives every row, exists at end. ──
|
|
251
|
+
it('shared fixture: every fixture file still exists after all rows complete', () => {
|
|
252
|
+
for (const rel of Object.values(FIXTURE_FILES)) {
|
|
253
|
+
expect(existsSync(join(process.cwd(), rel))).toBe(true);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
// ── WP-426 E3: round-trip — reprojection lands at authoringPath, no duplicate ─
|
|
258
|
+
//
|
|
259
|
+
// Simulates the DB → repo writeback loop (handshake.ts:1746-1840):
|
|
260
|
+
// 1. An asset was authored at .productbrain/skills/my-foo.md (name "Foo Bar").
|
|
261
|
+
// 2. The server now returns authoringPath: 'skills/my-foo.md' on the asset.
|
|
262
|
+
// 3. setupAuthoringPath resolves to the authored path (not Foo Bar.md).
|
|
263
|
+
// 4. Writing to that path creates my-foo.md only — no duplicate Foo Bar.md.
|
|
264
|
+
//
|
|
265
|
+
// Before WP-426 E3 the function ignored authoringPath and always name-derived,
|
|
266
|
+
// so "Foo Bar.md" would be written — a duplicate when the file was actually at
|
|
267
|
+
// "my-foo.md". This test locks the fixed behaviour.
|
|
268
|
+
import { __test as handshakeTest } from '../commands/handshake.js';
|
|
269
|
+
describe('WP-426 E3: round-trip — authoringPath wins; no duplicate file', () => {
|
|
270
|
+
let tmpRoot;
|
|
271
|
+
beforeAll(() => {
|
|
272
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'pb-wp426-e3-roundtrip-'));
|
|
273
|
+
mkdirSync(join(tmpRoot, '.productbrain', 'skills'), { recursive: true });
|
|
274
|
+
// Pre-place the file where the user originally authored it.
|
|
275
|
+
writeFileSync(join(tmpRoot, '.productbrain', 'skills', 'my-foo.md'), '---\nid: SETUP-FOO\nname: "Foo Bar"\n---\nContent.\n');
|
|
276
|
+
});
|
|
277
|
+
afterAll(() => {
|
|
278
|
+
rmSync(tmpRoot, { recursive: true, force: true });
|
|
279
|
+
});
|
|
280
|
+
it('resolves to the authoringPath location, not the name-derived location', () => {
|
|
281
|
+
const resolvedPath = handshakeTest.setupAuthoringPath(tmpRoot, {
|
|
282
|
+
entryId: 'SETUP-FOO',
|
|
283
|
+
name: 'Foo Bar',
|
|
284
|
+
assetKind: 'skill',
|
|
285
|
+
authoringPath: 'skills/my-foo.md',
|
|
286
|
+
});
|
|
287
|
+
expect(resolvedPath).toBe(join(tmpRoot, '.productbrain', 'skills', 'my-foo.md'));
|
|
288
|
+
// The name-derived path must NOT be what gets written.
|
|
289
|
+
expect(resolvedPath).not.toBe(join(tmpRoot, '.productbrain', 'skills', 'Foo Bar.md'));
|
|
290
|
+
});
|
|
291
|
+
it('writing to the resolved path does NOT create a duplicate Foo Bar.md', () => {
|
|
292
|
+
const resolvedPath = handshakeTest.setupAuthoringPath(tmpRoot, {
|
|
293
|
+
entryId: 'SETUP-FOO',
|
|
294
|
+
name: 'Foo Bar',
|
|
295
|
+
assetKind: 'skill',
|
|
296
|
+
authoringPath: 'skills/my-foo.md',
|
|
297
|
+
});
|
|
298
|
+
// Simulate the writeback: write to the resolved path (as the CLI does at handshake.ts:1840).
|
|
299
|
+
mkdirSync(dirname(resolvedPath), { recursive: true });
|
|
300
|
+
writeFileSync(resolvedPath, '---\nid: SETUP-FOO\nname: "Foo Bar"\n---\nReprojected content.\n');
|
|
301
|
+
// The authored file must exist at the authoringPath location.
|
|
302
|
+
expect(existsSync(join(tmpRoot, '.productbrain', 'skills', 'my-foo.md'))).toBe(true);
|
|
303
|
+
// The name-derived duplicate must NOT exist.
|
|
304
|
+
expect(existsSync(join(tmpRoot, '.productbrain', 'skills', 'Foo Bar.md'))).toBe(false);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
// ── WP-426 E3 (Loop fix): TRUE runHandshake round-trip ───────────────────────
|
|
308
|
+
//
|
|
309
|
+
// Drives the full `runHandshake()` write loop through its kernel stubs so the
|
|
310
|
+
// authoringPath field threads the complete path:
|
|
311
|
+
// server (listAssetsForUser) → dbAssetRows → personalAssets loop →
|
|
312
|
+
// setupAuthoringPath() → assertSetupWritePath → writeFileSync → disk
|
|
313
|
+
//
|
|
314
|
+
// doneWhen #3/#5 mandate that ".productbrain/skills/my-foo.md" lands on disk
|
|
315
|
+
// (not the name-derived "Foo Bar.md") and that .authoring-sync.json records
|
|
316
|
+
// the authoringPath-derived relative path for the entry.
|
|
317
|
+
//
|
|
318
|
+
// Why this sits in handshake.e2e.test.ts (not handshake-preview.test.ts):
|
|
319
|
+
// The preview file uses a virtual fs (vi.mock('fs')), which means writes go
|
|
320
|
+
// to an in-memory map and never touch the real filesystem. This test uses a
|
|
321
|
+
// REAL tempdir — writes are verified by existsSync/readFileSync on disk —
|
|
322
|
+
// proving the full I/O contract end-to-end.
|
|
323
|
+
//
|
|
324
|
+
// Kernel is stubbed via vi.doMock + vi.resetModules() per the probeStarterSetupSeeded
|
|
325
|
+
// pattern in handshake.test.ts. All other modules (generators, adapters, etc.)
|
|
326
|
+
// are stubbed to avoid real network calls while keeping the fs path real.
|
|
327
|
+
describe('WP-426 E3 (Loop fix): runHandshake round-trip — authoringPath lands on disk, no duplicate', () => {
|
|
328
|
+
let e2eRoot;
|
|
329
|
+
let cwdSpy;
|
|
330
|
+
beforeEach(() => {
|
|
331
|
+
vi.resetModules();
|
|
332
|
+
// Real tempdir — writes actually hit the filesystem.
|
|
333
|
+
e2eRoot = mkdtempSync(join(tmpdir(), 'pb-wp426-e3-loop-'));
|
|
334
|
+
mkdirSync(join(e2eRoot, '.productbrain'), { recursive: true });
|
|
335
|
+
// Real manifest.yaml so readManifestStatus (not mocked) returns mode='project'.
|
|
336
|
+
writeFileSync(join(e2eRoot, '.productbrain', 'manifest.yaml'), ['version: "0.1"', 'materialize: project', 'surfaces:', ' - .cursor', ' - .claude', ''].join('\n'));
|
|
337
|
+
// Point process.cwd() at the tempdir so runHandshake resolves all paths there.
|
|
338
|
+
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(e2eRoot);
|
|
339
|
+
// ── Kernel stub: returns one personal skill with authoringPath set ─────────
|
|
340
|
+
// The asset has no bodyStorageId → body-fetch loop is skipped entirely.
|
|
341
|
+
// scope: 'personal' → enters the personalAssets writeback branch.
|
|
342
|
+
// lastProjectedHash: null → no conflict check; DB writeback proceeds unconditionally.
|
|
343
|
+
vi.doMock('../lib/client.js', () => ({
|
|
344
|
+
kernelCall: vi.fn().mockImplementation(async (tool) => {
|
|
345
|
+
if (tool === 'chain.workspaceReadiness')
|
|
346
|
+
return null;
|
|
347
|
+
if (tool === 'chain.getOrientView')
|
|
348
|
+
return null;
|
|
349
|
+
if (tool === 'chain.searchEntries')
|
|
350
|
+
return [];
|
|
351
|
+
if (tool === 'setup.listAssetsForUser') {
|
|
352
|
+
return {
|
|
353
|
+
activeAssets: [
|
|
354
|
+
{
|
|
355
|
+
entryId: 'SETUP-SKILL-FOO',
|
|
356
|
+
name: 'Foo Bar',
|
|
357
|
+
description: 'A personal skill with an explicit authoring path.',
|
|
358
|
+
body: '# Foo Bar\nPersonal skill body.',
|
|
359
|
+
triggers: ['foo'],
|
|
360
|
+
assetKind: 'skill',
|
|
361
|
+
semanticRefs: [],
|
|
362
|
+
disabledByOwner: false,
|
|
363
|
+
lastProjectedHash: null,
|
|
364
|
+
scope: 'personal',
|
|
365
|
+
authoringPath: 'skills/my-foo.md', // ← WP-426 E3 field under test
|
|
366
|
+
},
|
|
367
|
+
],
|
|
368
|
+
dormantAssets: [],
|
|
369
|
+
hasAnyReceipt: true,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
if (tool === 'setup.updateLastProjectedHash')
|
|
373
|
+
return { ok: true };
|
|
374
|
+
if (tool === 'setup.getCurrentSetupState')
|
|
375
|
+
return { effectiveMode: 'project' };
|
|
376
|
+
if (tool === 'setup.materializeSetup')
|
|
377
|
+
return { ok: true, assetCount: 1, receiptWritten: true };
|
|
378
|
+
// Fail-open for any other call
|
|
379
|
+
return null;
|
|
380
|
+
}),
|
|
381
|
+
kernelCallWithSession: vi.fn().mockResolvedValue(null),
|
|
382
|
+
}));
|
|
383
|
+
// ── Config: return a valid auth config ────────────────────────────────────
|
|
384
|
+
vi.doMock('../lib/config.js', () => ({
|
|
385
|
+
getConfigOrGuide: vi.fn().mockResolvedValue({
|
|
386
|
+
apiKey: 'pb_sk_e2e_test',
|
|
387
|
+
siteUrl: 'https://test.convex.site',
|
|
388
|
+
}),
|
|
389
|
+
}));
|
|
390
|
+
// ── Repo detection: minimal stub ──────────────────────────────────────────
|
|
391
|
+
vi.doMock('../lib/repo-detect.js', () => ({
|
|
392
|
+
detectRepo: vi.fn().mockReturnValue({ name: 'e2e-test-repo', detectedStack: [], repoSlug: null }),
|
|
393
|
+
extractWorkspaceProfile: vi.fn().mockReturnValue(null),
|
|
394
|
+
}));
|
|
395
|
+
// ── Generator stubs: return minimal content; no disk reads needed ─────────
|
|
396
|
+
vi.doMock('../generators/adapters.js', () => ({
|
|
397
|
+
MARKER: 'auto-generated by pb handshake',
|
|
398
|
+
generateAgentsMd: vi.fn().mockReturnValue('<!-- auto-generated by pb handshake -->\nagents'),
|
|
399
|
+
generateClaudeMd: vi.fn().mockReturnValue('<!-- auto-generated by pb handshake -->\nclaude'),
|
|
400
|
+
generateCursorMdc: vi.fn().mockReturnValue('<!-- auto-generated by pb handshake -->\ncursor'),
|
|
401
|
+
generateCopilotMd: vi.fn().mockReturnValue('<!-- auto-generated by pb handshake -->\ncopilot'),
|
|
402
|
+
}));
|
|
403
|
+
vi.doMock('../generators/portable-knowledge.js', () => ({
|
|
404
|
+
readCanonicalSkills: vi.fn().mockReturnValue([]),
|
|
405
|
+
readCanonicalRules: vi.fn().mockReturnValue([]),
|
|
406
|
+
readPersonalLayer: vi.fn().mockReturnValue([]),
|
|
407
|
+
readPersonalSkillsLayer: vi.fn().mockReturnValue([]),
|
|
408
|
+
generateCursorSkill: vi.fn().mockReturnValue(''),
|
|
409
|
+
generateCursorRule: vi.fn().mockReturnValue(''),
|
|
410
|
+
generateCodexSkill: vi.fn().mockReturnValue(''),
|
|
411
|
+
generateClaudeRule: vi.fn().mockReturnValue(''),
|
|
412
|
+
generateCodexSkillIndex: vi.fn().mockReturnValue(''),
|
|
413
|
+
generateClaudeSkillRouter: vi.fn().mockReturnValue(''),
|
|
414
|
+
shouldEmitToTarget: vi.fn().mockReturnValue(false),
|
|
415
|
+
filterByLevel: vi.fn().mockImplementation((items) => items),
|
|
416
|
+
evaluateConditions: vi.fn().mockReturnValue({ included: true, reasons: [] }),
|
|
417
|
+
validateCodexSkills: vi.fn().mockReturnValue([]),
|
|
418
|
+
STAGE_TO_MAX_LEVEL: {},
|
|
419
|
+
LEVEL_ORDER: [],
|
|
420
|
+
}));
|
|
421
|
+
vi.doMock('../generators/briefing-md.js', () => ({
|
|
422
|
+
generateBriefingMd: vi.fn().mockReturnValue('briefing content'),
|
|
423
|
+
}));
|
|
424
|
+
vi.doMock('../generators/context-md.js', () => ({
|
|
425
|
+
generateContextMd: vi.fn().mockReturnValue('context content'),
|
|
426
|
+
}));
|
|
427
|
+
vi.doMock('../generators/chain-rules.js', () => ({
|
|
428
|
+
generateChainRules: vi.fn().mockResolvedValue({ content: '', rules: [], classified: {}, stats: { generatedRules: 0, suppressedByManual: 0, suppressedByZeroEntries: 0 }, gaps: [], sentinel: null }),
|
|
429
|
+
}));
|
|
430
|
+
vi.doMock('../generators/handshake-diff.js', () => ({
|
|
431
|
+
saveHandshakeState: vi.fn(),
|
|
432
|
+
loadPreviousState: vi.fn().mockReturnValue(null),
|
|
433
|
+
diffHandshakeState: vi.fn().mockReturnValue({ added: [], updated: [], removed: [] }),
|
|
434
|
+
formatDiff: vi.fn().mockReturnValue(''),
|
|
435
|
+
buildCurrentState: vi.fn().mockReturnValue({}),
|
|
436
|
+
}));
|
|
437
|
+
vi.doMock('../generators/surface-profiles.js', () => ({
|
|
438
|
+
resolveSurfaceProfile: vi.fn().mockReturnValue({ level: 'intermediate' }),
|
|
439
|
+
}));
|
|
440
|
+
vi.doMock('../generators/boundary-manifest.js', () => ({
|
|
441
|
+
generateBoundaryManifest: vi.fn().mockReturnValue(null),
|
|
442
|
+
getBoundaryEnforcementMode: vi.fn().mockReturnValue('advisory'),
|
|
443
|
+
}));
|
|
444
|
+
vi.doMock('../formatters/handshake.js', () => ({
|
|
445
|
+
formatHandshakeReport: vi.fn().mockReturnValue(''),
|
|
446
|
+
}));
|
|
447
|
+
vi.doMock('../lib/hook-intents.js', () => ({
|
|
448
|
+
composeHooksFromIntents: vi.fn().mockReturnValue({}),
|
|
449
|
+
getHookStatusForSurface: vi.fn().mockReturnValue([]),
|
|
450
|
+
}));
|
|
451
|
+
vi.doMock('../lib/method-registry.js', () => ({
|
|
452
|
+
loadMethodRegistry: vi.fn().mockResolvedValue({ methods: [], source: 'bundled', stale: false }),
|
|
453
|
+
}));
|
|
454
|
+
vi.doMock('../lib/workspaceVocabCache.js', () => ({
|
|
455
|
+
getOrFetchVocabCtx: vi.fn().mockResolvedValue(null),
|
|
456
|
+
}));
|
|
457
|
+
vi.doMock('../lib/telemetry.js', () => ({
|
|
458
|
+
trackEvent: vi.fn(),
|
|
459
|
+
}));
|
|
460
|
+
vi.doMock('../lib/session.js', () => ({
|
|
461
|
+
readSession: vi.fn().mockReturnValue(null),
|
|
462
|
+
}));
|
|
463
|
+
vi.doMock('../lib/canonicalRefs.js', () => ({
|
|
464
|
+
parseSemanticRefs: vi.fn().mockReturnValue([]),
|
|
465
|
+
replaceVocabTokens: vi.fn().mockImplementation((content) => content),
|
|
466
|
+
}));
|
|
467
|
+
vi.doMock('../surfaces/telemetry.js', () => ({
|
|
468
|
+
getReverseMapFallbackMessage: vi.fn().mockReturnValue(''),
|
|
469
|
+
reportReverseMapMissing: vi.fn(),
|
|
470
|
+
}));
|
|
471
|
+
});
|
|
472
|
+
afterEach(() => {
|
|
473
|
+
cwdSpy.mockRestore();
|
|
474
|
+
rmSync(e2eRoot, { recursive: true, force: true });
|
|
475
|
+
vi.restoreAllMocks();
|
|
476
|
+
});
|
|
477
|
+
it('DB asset with authoringPath lands at skills/my-foo.md, not at "Foo Bar.md"', async () => {
|
|
478
|
+
// Import runHandshake fresh in the doMock module scope.
|
|
479
|
+
const { runHandshake } = await import('../commands/handshake.js');
|
|
480
|
+
// Codex P2: capture stderr to prove the raise-cleanup pass does NOT log a false
|
|
481
|
+
// warning for the .codex surface — this workspace only enables .cursor + .claude.
|
|
482
|
+
const stderrLines = [];
|
|
483
|
+
vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
|
|
484
|
+
if (typeof chunk === 'string')
|
|
485
|
+
stderrLines.push(chunk);
|
|
486
|
+
return true;
|
|
487
|
+
});
|
|
488
|
+
// Drive the full loop: apply=true activates writeMode which enters the
|
|
489
|
+
// personalAssets authoring-file writeback branch (handshake.ts:1733–1858).
|
|
490
|
+
await runHandshake({ apply: true });
|
|
491
|
+
// ── Assertion (a): file written at the authoringPath-derived location ──────
|
|
492
|
+
const authoredPath = join(e2eRoot, '.productbrain', 'skills', 'my-foo.md');
|
|
493
|
+
expect(existsSync(authoredPath), '.productbrain/skills/my-foo.md must exist after runHandshake writes back from DB').toBe(true);
|
|
494
|
+
// ── Assertion (b): no name-derived duplicate created ──────────────────────
|
|
495
|
+
const nameDerivedPath = join(e2eRoot, '.productbrain', 'skills', 'Foo Bar.md');
|
|
496
|
+
expect(existsSync(nameDerivedPath), '.productbrain/skills/Foo Bar.md must NOT be created (authoringPath wins)').toBe(false);
|
|
497
|
+
// ── Assertion (c): .authoring-sync.json records the correct relative path ──
|
|
498
|
+
const syncPath = join(e2eRoot, '.productbrain', '.authoring-sync.json');
|
|
499
|
+
expect(existsSync(syncPath), '.authoring-sync.json must be written by the sync-state persistence step').toBe(true);
|
|
500
|
+
const syncState = JSON.parse(readFileSync(syncPath, 'utf8'));
|
|
501
|
+
expect(syncState.version).toBe(1);
|
|
502
|
+
const entry = syncState.assets['SETUP-SKILL-FOO'];
|
|
503
|
+
expect(entry, '.authoring-sync.json must contain an entry for SETUP-SKILL-FOO').toBeDefined();
|
|
504
|
+
expect(entry.path).toBe('.productbrain/skills/my-foo.md');
|
|
505
|
+
expect(entry.path, 'sync entry path must be the authoringPath-derived relative path, not the name-derived one').not.toBe('.productbrain/skills/Foo Bar.md');
|
|
506
|
+
// ── Content sanity: written file contains the entryId frontmatter ─────────
|
|
507
|
+
const written = readFileSync(authoredPath, 'utf8');
|
|
508
|
+
expect(written).toContain('id: SETUP-SKILL-FOO');
|
|
509
|
+
expect(written).toContain('# Foo Bar');
|
|
510
|
+
// ── (Codex P2): the disabled .codex surface must be skipped silently — no
|
|
511
|
+
// false "raise-cleanup failed" / "could not dormant-mark" warning. ───────
|
|
512
|
+
const allStderr = stderrLines.join('');
|
|
513
|
+
expect(allStderr, 'no false raise-cleanup warning for the disabled .codex surface').not.toContain('raise-cleanup failed');
|
|
514
|
+
expect(allStderr).not.toContain('could not dormant-mark');
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
// ── WP-426 E4: lowering — .dormant rename + purge cue + authoring copy untouched ─
|
|
518
|
+
//
|
|
519
|
+
// Drives runHandshake() with a kernel stub that returns the Foo asset in
|
|
520
|
+
// dormantAssets. Pre-places the projected surface files (with MARKER) and the
|
|
521
|
+
// .productbrain authoring copy. After runHandshake({apply:true}):
|
|
522
|
+
// - .codex/skills/Foo.md.dormant ← renamed from .codex/skills/Foo.md
|
|
523
|
+
// - .cursor/rules/Foo.mdc.dormant ← renamed from .cursor/rules/Foo.mdc
|
|
524
|
+
// - originals (.md / .mdc) are gone
|
|
525
|
+
// - .productbrain/skills/Foo.md is UNTOUCHED (deriveDormantFilePaths never returns .productbrain paths)
|
|
526
|
+
// - stdout contains the purge cue
|
|
527
|
+
//
|
|
528
|
+
// Manual reactivation:
|
|
529
|
+
// After the lowering run, simulate the user renaming .codex/skills/Foo.md.dormant
|
|
530
|
+
// back to .codex/skills/Foo.md. Re-run with the asset still dormant. Assert:
|
|
531
|
+
// - .codex/skills/Foo.md is untouched (no re-dormant)
|
|
532
|
+
// - a drift TEN was queued via kernelCallWithSession('chain.createEntry', …)
|
|
533
|
+
describe('WP-426 E4: lowering — .dormant rename + purge cue + authoring copy untouched', () => {
|
|
534
|
+
let e2eRoot;
|
|
535
|
+
let cwdSpy;
|
|
536
|
+
let stdoutLines;
|
|
537
|
+
let kernelCallWithSessionMock;
|
|
538
|
+
// ── Shared stub factory ───────────────────────────────────────────────────
|
|
539
|
+
async function setupMocks(opts = {}) {
|
|
540
|
+
vi.resetModules();
|
|
541
|
+
e2eRoot = mkdtempSync(join(tmpdir(), 'pb-wp426-e4-lower-'));
|
|
542
|
+
mkdirSync(join(e2eRoot, '.productbrain', 'skills'), { recursive: true });
|
|
543
|
+
mkdirSync(join(e2eRoot, '.codex', 'skills'), { recursive: true });
|
|
544
|
+
mkdirSync(join(e2eRoot, '.cursor', 'rules'), { recursive: true });
|
|
545
|
+
// Real manifest.yaml — mode='project' so writeMode is active
|
|
546
|
+
writeFileSync(join(e2eRoot, '.productbrain', 'manifest.yaml'), ['version: "0.1"', 'materialize: project', 'surfaces:', ' - .codex', ' - .cursor', ' - .claude', ''].join('\n'));
|
|
547
|
+
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(e2eRoot);
|
|
548
|
+
stdoutLines = [];
|
|
549
|
+
vi.spyOn(process.stdout, 'write').mockImplementation((chunk, ...rest) => {
|
|
550
|
+
if (typeof chunk === 'string')
|
|
551
|
+
stdoutLines.push(chunk);
|
|
552
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
553
|
+
return process.stdout.write.__originalImpl ? true : true;
|
|
554
|
+
});
|
|
555
|
+
kernelCallWithSessionMock = vi.fn().mockResolvedValue(null);
|
|
556
|
+
vi.doMock('../lib/client.js', () => ({
|
|
557
|
+
kernelCall: vi.fn().mockImplementation(async (tool) => {
|
|
558
|
+
if (tool === 'chain.workspaceReadiness')
|
|
559
|
+
return null;
|
|
560
|
+
if (tool === 'chain.getOrientView')
|
|
561
|
+
return null;
|
|
562
|
+
if (tool === 'chain.searchEntries')
|
|
563
|
+
return [];
|
|
564
|
+
if (tool === 'setup.listAssetsForUser') {
|
|
565
|
+
return {
|
|
566
|
+
activeAssets: opts.activeAssets ?? [],
|
|
567
|
+
dormantAssets: opts.dormantAssets ?? [
|
|
568
|
+
{
|
|
569
|
+
entryId: 'SETUP-SKILL-FOO',
|
|
570
|
+
name: 'Foo',
|
|
571
|
+
description: 'A dormant skill',
|
|
572
|
+
body: '# Foo\nSkill body.',
|
|
573
|
+
triggers: [],
|
|
574
|
+
assetKind: 'skill',
|
|
575
|
+
semanticRefs: [],
|
|
576
|
+
disabledByOwner: false,
|
|
577
|
+
lastProjectedHash: null,
|
|
578
|
+
},
|
|
579
|
+
{
|
|
580
|
+
entryId: 'SETUP-RULE-FOO',
|
|
581
|
+
name: 'Foo',
|
|
582
|
+
description: 'A dormant rule',
|
|
583
|
+
body: '# Foo\nRule body.',
|
|
584
|
+
triggers: [],
|
|
585
|
+
assetKind: 'rule',
|
|
586
|
+
semanticRefs: [],
|
|
587
|
+
disabledByOwner: false,
|
|
588
|
+
lastProjectedHash: null,
|
|
589
|
+
},
|
|
590
|
+
],
|
|
591
|
+
hasAnyReceipt: true,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
if (tool === 'setup.updateLastProjectedHash')
|
|
595
|
+
return { ok: true };
|
|
596
|
+
if (tool === 'setup.getCurrentSetupState')
|
|
597
|
+
return { effectiveMode: 'project' };
|
|
598
|
+
if (tool === 'setup.materializeSetup')
|
|
599
|
+
return { ok: true, assetCount: 0, receiptWritten: true };
|
|
600
|
+
return null;
|
|
601
|
+
}),
|
|
602
|
+
kernelCallWithSession: kernelCallWithSessionMock,
|
|
603
|
+
}));
|
|
604
|
+
vi.doMock('../lib/config.js', () => ({
|
|
605
|
+
getConfigOrGuide: vi.fn().mockResolvedValue({ apiKey: 'pb_sk_e2e_test', siteUrl: 'https://test.convex.site' }),
|
|
606
|
+
}));
|
|
607
|
+
vi.doMock('../lib/repo-detect.js', () => ({
|
|
608
|
+
detectRepo: vi.fn().mockReturnValue({ name: 'e2e-test-repo', detectedStack: [], repoSlug: null }),
|
|
609
|
+
extractWorkspaceProfile: vi.fn().mockReturnValue(null),
|
|
610
|
+
}));
|
|
611
|
+
vi.doMock('../generators/adapters.js', () => ({
|
|
612
|
+
MARKER: 'auto-generated by pb handshake',
|
|
613
|
+
generateAgentsMd: vi.fn().mockReturnValue('<!-- auto-generated by pb handshake -->\nagents'),
|
|
614
|
+
generateClaudeMd: vi.fn().mockReturnValue('<!-- auto-generated by pb handshake -->\nclaude'),
|
|
615
|
+
generateCursorMdc: vi.fn().mockReturnValue('<!-- auto-generated by pb handshake -->\ncursor'),
|
|
616
|
+
generateCopilotMd: vi.fn().mockReturnValue('<!-- auto-generated by pb handshake -->\ncopilot'),
|
|
617
|
+
}));
|
|
618
|
+
vi.doMock('../generators/portable-knowledge.js', () => ({
|
|
619
|
+
readCanonicalSkills: vi.fn().mockReturnValue([]),
|
|
620
|
+
readCanonicalRules: vi.fn().mockReturnValue([]),
|
|
621
|
+
readPersonalLayer: vi.fn().mockReturnValue([]),
|
|
622
|
+
readPersonalSkillsLayer: vi.fn().mockReturnValue([]),
|
|
623
|
+
generateCursorSkill: vi.fn().mockReturnValue(''),
|
|
624
|
+
generateCursorRule: vi.fn().mockReturnValue(''),
|
|
625
|
+
generateCodexSkill: vi.fn().mockReturnValue(''),
|
|
626
|
+
generateClaudeRule: vi.fn().mockReturnValue(''),
|
|
627
|
+
generateCodexSkillIndex: vi.fn().mockReturnValue(''),
|
|
628
|
+
generateClaudeSkillRouter: vi.fn().mockReturnValue(''),
|
|
629
|
+
shouldEmitToTarget: vi.fn().mockReturnValue(false),
|
|
630
|
+
filterByLevel: vi.fn().mockImplementation((items) => items),
|
|
631
|
+
evaluateConditions: vi.fn().mockReturnValue({ included: true, reasons: [] }),
|
|
632
|
+
validateCodexSkills: vi.fn().mockReturnValue([]),
|
|
633
|
+
STAGE_TO_MAX_LEVEL: {},
|
|
634
|
+
LEVEL_ORDER: [],
|
|
635
|
+
}));
|
|
636
|
+
vi.doMock('../generators/briefing-md.js', () => ({ generateBriefingMd: vi.fn().mockReturnValue('briefing') }));
|
|
637
|
+
vi.doMock('../generators/context-md.js', () => ({ generateContextMd: vi.fn().mockReturnValue('context') }));
|
|
638
|
+
vi.doMock('../generators/chain-rules.js', () => ({
|
|
639
|
+
generateChainRules: vi.fn().mockResolvedValue({ content: '', rules: [], classified: {}, stats: { generatedRules: 0, suppressedByManual: 0, suppressedByZeroEntries: 0 }, gaps: [], sentinel: null }),
|
|
640
|
+
}));
|
|
641
|
+
vi.doMock('../generators/handshake-diff.js', () => ({
|
|
642
|
+
saveHandshakeState: vi.fn(),
|
|
643
|
+
loadPreviousState: vi.fn().mockReturnValue(null),
|
|
644
|
+
diffHandshakeState: vi.fn().mockReturnValue({ added: [], updated: [], removed: [] }),
|
|
645
|
+
formatDiff: vi.fn().mockReturnValue(''),
|
|
646
|
+
buildCurrentState: vi.fn().mockReturnValue({}),
|
|
647
|
+
}));
|
|
648
|
+
vi.doMock('../generators/surface-profiles.js', () => ({
|
|
649
|
+
resolveSurfaceProfile: vi.fn().mockReturnValue({ level: 'intermediate' }),
|
|
650
|
+
}));
|
|
651
|
+
vi.doMock('../generators/boundary-manifest.js', () => ({
|
|
652
|
+
generateBoundaryManifest: vi.fn().mockReturnValue(null),
|
|
653
|
+
getBoundaryEnforcementMode: vi.fn().mockReturnValue('advisory'),
|
|
654
|
+
}));
|
|
655
|
+
vi.doMock('../formatters/handshake.js', () => ({ formatHandshakeReport: vi.fn().mockReturnValue('') }));
|
|
656
|
+
vi.doMock('../lib/hook-intents.js', () => ({
|
|
657
|
+
composeHooksFromIntents: vi.fn().mockReturnValue({}),
|
|
658
|
+
getHookStatusForSurface: vi.fn().mockReturnValue([]),
|
|
659
|
+
}));
|
|
660
|
+
vi.doMock('../lib/method-registry.js', () => ({
|
|
661
|
+
loadMethodRegistry: vi.fn().mockResolvedValue({ methods: [], source: 'bundled', stale: false }),
|
|
662
|
+
}));
|
|
663
|
+
vi.doMock('../lib/workspaceVocabCache.js', () => ({ getOrFetchVocabCtx: vi.fn().mockResolvedValue(null) }));
|
|
664
|
+
vi.doMock('../lib/telemetry.js', () => ({ trackEvent: vi.fn() }));
|
|
665
|
+
vi.doMock('../lib/session.js', () => ({
|
|
666
|
+
readSession: vi.fn().mockReturnValue({ sessionId: 'test-session-id', userId: 'test-user' }),
|
|
667
|
+
}));
|
|
668
|
+
vi.doMock('../lib/canonicalRefs.js', () => ({
|
|
669
|
+
parseSemanticRefs: vi.fn().mockReturnValue([]),
|
|
670
|
+
replaceVocabTokens: vi.fn().mockImplementation((content) => content),
|
|
671
|
+
}));
|
|
672
|
+
vi.doMock('../surfaces/telemetry.js', () => ({
|
|
673
|
+
getReverseMapFallbackMessage: vi.fn().mockReturnValue(''),
|
|
674
|
+
reportReverseMapMissing: vi.fn(),
|
|
675
|
+
}));
|
|
676
|
+
}
|
|
677
|
+
afterEach(() => {
|
|
678
|
+
cwdSpy?.mockRestore();
|
|
679
|
+
if (e2eRoot && existsSync(e2eRoot))
|
|
680
|
+
rmSync(e2eRoot, { recursive: true, force: true });
|
|
681
|
+
vi.restoreAllMocks();
|
|
682
|
+
});
|
|
683
|
+
it('E4: lowering — .dormant files created for ALL surface paths (skill + rule), originals gone, authoring copy untouched, purge cue in stdout', async () => {
|
|
684
|
+
await setupMocks();
|
|
685
|
+
// FIX 3: pre-place ALL four surface paths that deriveDormantFilePaths emits
|
|
686
|
+
// for the two dormant assets (skill "Foo" + rule "Foo"):
|
|
687
|
+
// skill → .cursor/skills/Foo/SKILL.md (cursor skill subdir)
|
|
688
|
+
// + .codex/skills/Foo.md (codex skill)
|
|
689
|
+
// rule → .cursor/rules/Foo.mdc (cursor rule)
|
|
690
|
+
// + .claude/rules/Foo.md (claude rule)
|
|
691
|
+
mkdirSync(join(e2eRoot, '.cursor', 'skills', 'Foo'), { recursive: true });
|
|
692
|
+
mkdirSync(join(e2eRoot, '.claude', 'rules'), { recursive: true });
|
|
693
|
+
const cursorSkillPath = join(e2eRoot, '.cursor', 'skills', 'Foo', 'SKILL.md');
|
|
694
|
+
const codexSkillPath = join(e2eRoot, '.codex', 'skills', 'Foo.md');
|
|
695
|
+
const cursorRulePath = join(e2eRoot, '.cursor', 'rules', 'Foo.mdc');
|
|
696
|
+
const claudeRulePath = join(e2eRoot, '.claude', 'rules', 'Foo.md');
|
|
697
|
+
const authoringCopy = join(e2eRoot, '.productbrain', 'skills', 'Foo.md');
|
|
698
|
+
const markedContent = (label) => `<!-- auto-generated by pb handshake -->\n# Foo ${label}\n`;
|
|
699
|
+
writeFileSync(cursorSkillPath, markedContent('cursor-skill'));
|
|
700
|
+
writeFileSync(codexSkillPath, markedContent('codex-skill'));
|
|
701
|
+
writeFileSync(cursorRulePath, markedContent('cursor-rule'));
|
|
702
|
+
writeFileSync(claudeRulePath, markedContent('claude-rule'));
|
|
703
|
+
writeFileSync(authoringCopy, '---\nid: SETUP-SKILL-FOO\nname: "Foo"\n---\nHand-authored content.\n');
|
|
704
|
+
const { runHandshake } = await import('../commands/handshake.js');
|
|
705
|
+
await runHandshake({ apply: true });
|
|
706
|
+
// ── All four .dormant files must be created
|
|
707
|
+
expect(existsSync(`${cursorSkillPath}.dormant`), '.cursor/skills/Foo/SKILL.md.dormant must exist').toBe(true);
|
|
708
|
+
expect(existsSync(`${codexSkillPath}.dormant`), '.codex/skills/Foo.md.dormant must exist').toBe(true);
|
|
709
|
+
expect(existsSync(`${cursorRulePath}.dormant`), '.cursor/rules/Foo.mdc.dormant must exist').toBe(true);
|
|
710
|
+
expect(existsSync(`${claudeRulePath}.dormant`), '.claude/rules/Foo.md.dormant must exist').toBe(true);
|
|
711
|
+
// ── All four originals must be gone
|
|
712
|
+
expect(existsSync(cursorSkillPath), '.cursor/skills/Foo/SKILL.md original must be gone').toBe(false);
|
|
713
|
+
expect(existsSync(codexSkillPath), '.codex/skills/Foo.md original must be gone').toBe(false);
|
|
714
|
+
expect(existsSync(cursorRulePath), '.cursor/rules/Foo.mdc original must be gone').toBe(false);
|
|
715
|
+
expect(existsSync(claudeRulePath), '.claude/rules/Foo.md original must be gone').toBe(false);
|
|
716
|
+
// ── .productbrain authoring copy must be untouched
|
|
717
|
+
expect(existsSync(authoringCopy), '.productbrain/skills/Foo.md authoring copy must be untouched').toBe(true);
|
|
718
|
+
// ── purge cue in stdout
|
|
719
|
+
const allOutput = stdoutLines.join('');
|
|
720
|
+
expect(allOutput, 'stdout must contain the purge cue').toContain('pb setup observe --purge');
|
|
721
|
+
// ── .authoring-sync.json dormantRenamed registry must include all four surface paths
|
|
722
|
+
const syncPath = join(e2eRoot, '.productbrain', '.authoring-sync.json');
|
|
723
|
+
expect(existsSync(syncPath), '.authoring-sync.json must exist').toBe(true);
|
|
724
|
+
const syncState = JSON.parse(readFileSync(syncPath, 'utf8'));
|
|
725
|
+
expect(syncState.dormantRenamed, 'dormantRenamed must include cursor skill subdir path').toContain('.cursor/skills/Foo/SKILL.md');
|
|
726
|
+
expect(syncState.dormantRenamed, 'dormantRenamed must include codex skill path').toContain('.codex/skills/Foo.md');
|
|
727
|
+
expect(syncState.dormantRenamed, 'dormantRenamed must include cursor rule path').toContain('.cursor/rules/Foo.mdc');
|
|
728
|
+
expect(syncState.dormantRenamed, 'dormantRenamed must include claude rule path').toContain('.claude/rules/Foo.md');
|
|
729
|
+
});
|
|
730
|
+
it('E4: manual reactivation — reactivated file left untouched, drift TEN queued ONCE; N+3 run never re-dormants', async () => {
|
|
731
|
+
// FIX 2: Strengthened reactivation test.
|
|
732
|
+
// Run N+1 (detection): pre-staged state → TEN fires, path moves to dormantReactivated.
|
|
733
|
+
// Run N+2 (hands-off): path in dormantReactivated → skipped silently, file still live, NO second TEN.
|
|
734
|
+
await setupMocks();
|
|
735
|
+
const skillPath = join(e2eRoot, '.codex', 'skills', 'Foo.md');
|
|
736
|
+
// ── Pre-stage a post-lowering filesystem state:
|
|
737
|
+
// • .codex/skills/Foo.md.dormant exists (was previously lowered)
|
|
738
|
+
// • .productbrain/.authoring-sync.json already has the registry entry
|
|
739
|
+
// • The user has then renamed .dormant back to .md (manual reactivation)
|
|
740
|
+
//
|
|
741
|
+
// We do NOT run a first handshake — we directly write the state that would
|
|
742
|
+
// exist after a prior lowering run. This avoids module-reset issues between
|
|
743
|
+
// two sequential runHandshake calls in the same test.
|
|
744
|
+
// .dormant file from prior lowering (user will "manually rename" it back)
|
|
745
|
+
writeFileSync(`${skillPath}.dormant`, '<!-- auto-generated by pb handshake -->\n# Foo\nSkill body.\n<!-- pb-status: dormant -->\n');
|
|
746
|
+
// Simulate user renaming .dormant → live (manual reactivation)
|
|
747
|
+
renameSync(`${skillPath}.dormant`, skillPath);
|
|
748
|
+
expect(existsSync(skillPath)).toBe(true);
|
|
749
|
+
expect(existsSync(`${skillPath}.dormant`)).toBe(false);
|
|
750
|
+
// Pre-write the .authoring-sync.json registry as if the prior lowering run had done it.
|
|
751
|
+
// Only skill path in dormantRenamed; no dormantReactivated yet.
|
|
752
|
+
const syncPath = join(e2eRoot, '.productbrain', '.authoring-sync.json');
|
|
753
|
+
writeFileSync(syncPath, JSON.stringify({
|
|
754
|
+
version: 1,
|
|
755
|
+
assets: {},
|
|
756
|
+
dormantRenamed: ['.codex/skills/Foo.md', '.cursor/rules/Foo.mdc'],
|
|
757
|
+
dormantReactivated: [],
|
|
758
|
+
}, null, 2) + '\n');
|
|
759
|
+
// Also ensure the rule .dormant is present (not manually reactivated, only the skill was)
|
|
760
|
+
mkdirSync(join(e2eRoot, '.cursor', 'rules'), { recursive: true });
|
|
761
|
+
const ruleDormantPath = join(e2eRoot, '.cursor', 'rules', 'Foo.mdc.dormant');
|
|
762
|
+
writeFileSync(ruleDormantPath, '<!-- auto-generated by pb handshake -->\n# Foo\nRule body.\n<!-- pb-status: dormant -->\n');
|
|
763
|
+
// ── Run N+1: FIRST detection of manual reactivation
|
|
764
|
+
const { runHandshake: runHandshake1 } = await import('../commands/handshake.js');
|
|
765
|
+
await runHandshake1({ apply: true });
|
|
766
|
+
// ── After N+1: reactivated file must remain live
|
|
767
|
+
expect(existsSync(skillPath), 'N+1: .codex/skills/Foo.md must remain live (user reactivated it)').toBe(true);
|
|
768
|
+
expect(existsSync(`${skillPath}.dormant`), 'N+1: .dormant must NOT be recreated for the reactivated file').toBe(false);
|
|
769
|
+
// ── After N+1: .authoring-sync.json must have path in dormantReactivated, not dormantRenamed (FIX 2a)
|
|
770
|
+
const syncState1 = JSON.parse(readFileSync(syncPath, 'utf8'));
|
|
771
|
+
expect(syncState1.dormantReactivated, 'N+1: dormantReactivated must include the reactivated skill path').toContain('.codex/skills/Foo.md');
|
|
772
|
+
expect(syncState1.dormantRenamed ?? [], 'N+1: dormantRenamed must NOT include the reactivated skill path').not.toContain('.codex/skills/Foo.md');
|
|
773
|
+
// ── After N+1: exactly one drift TEN was queued
|
|
774
|
+
const createEntryCalls1 = kernelCallWithSessionMock.mock.calls.filter((call) => call[0] === 'chain.createEntry');
|
|
775
|
+
expect(createEntryCalls1.length, 'N+1: at least one chain.createEntry call (drift TEN) must be made').toBeGreaterThan(0);
|
|
776
|
+
const driftCall1 = createEntryCalls1.find((call) => {
|
|
777
|
+
const args = call[1];
|
|
778
|
+
return (args.collectionSlug === 'tensions' &&
|
|
779
|
+
typeof args.data?.description === 'string' &&
|
|
780
|
+
args.data.description.includes('manually reactivated'));
|
|
781
|
+
});
|
|
782
|
+
expect(driftCall1, 'N+1: drift TEN for manual reactivation must be queued').toBeDefined();
|
|
783
|
+
const tenCountAfterN1 = createEntryCalls1.filter((call) => {
|
|
784
|
+
const args = call[1];
|
|
785
|
+
return (args.collectionSlug === 'tensions' &&
|
|
786
|
+
args.data.description?.includes('manually reactivated'));
|
|
787
|
+
}).length;
|
|
788
|
+
// ── Run N+2 (FIX 2b): asset still dormant, path now in dormantReactivated.
|
|
789
|
+
// The hands-off set must prevent any re-dormant AND any second TEN.
|
|
790
|
+
vi.resetModules();
|
|
791
|
+
// Re-setup mocks for the second run (fresh module registry)
|
|
792
|
+
await setupMocks();
|
|
793
|
+
// Restore the .authoring-sync.json state that N+1 left (setupMocks creates a fresh e2eRoot, so we must
|
|
794
|
+
// re-use the same root by restoring after setupMocks overwrites it).
|
|
795
|
+
// Since setupMocks creates a new e2eRoot, we need to write the N+1 post-state into the new root.
|
|
796
|
+
// Instead, we directly write the dormantReactivated state to simulate what N+1 wrote.
|
|
797
|
+
writeFileSync(skillPath, '<!-- auto-generated by pb handshake -->\n# Foo\nSkill body.\n');
|
|
798
|
+
writeFileSync(syncPath, JSON.stringify({
|
|
799
|
+
version: 1,
|
|
800
|
+
assets: {},
|
|
801
|
+
dormantRenamed: ['.cursor/rules/Foo.mdc'], // skill moved out
|
|
802
|
+
dormantReactivated: ['.codex/skills/Foo.md'], // skill in hands-off set
|
|
803
|
+
}, null, 2) + '\n');
|
|
804
|
+
// Rule dormant still present (untouched)
|
|
805
|
+
writeFileSync(join(e2eRoot, '.cursor', 'rules', 'Foo.mdc.dormant'), '<!-- auto-generated by pb handshake -->\n# Foo\nRule body.\n<!-- pb-status: dormant -->\n');
|
|
806
|
+
const { runHandshake: runHandshake2 } = await import('../commands/handshake.js');
|
|
807
|
+
await runHandshake2({ apply: true });
|
|
808
|
+
// ── After N+2: skill file must STILL be live (not re-dormanted)
|
|
809
|
+
expect(existsSync(skillPath), 'N+2: .codex/skills/Foo.md must still be live (hands-off set)').toBe(true);
|
|
810
|
+
expect(existsSync(`${skillPath}.dormant`), 'N+2: .dormant must NOT be created on the second run').toBe(false);
|
|
811
|
+
// ── After N+2: NO new drift TEN for the already-reactivated path (TEN fires once)
|
|
812
|
+
const createEntryCalls2 = kernelCallWithSessionMock.mock.calls.filter((call) => call[0] === 'chain.createEntry');
|
|
813
|
+
const newReactivationTens = createEntryCalls2.filter((call) => {
|
|
814
|
+
const args = call[1];
|
|
815
|
+
return (args.collectionSlug === 'tensions' &&
|
|
816
|
+
args.data.description?.includes('manually reactivated'));
|
|
817
|
+
}).length;
|
|
818
|
+
expect(newReactivationTens, 'N+2: no additional reactivation TEN must be queued (hands-off set; TEN fires once)').toBe(0);
|
|
819
|
+
});
|
|
820
|
+
// Codex P2: dormant renames must honor the run's --surfaces selection, not just the
|
|
821
|
+
// perimeter. `--surfaces cursor` may only rename cursor surfaces; .claude/.codex must
|
|
822
|
+
// be left untouched even though they are declared in manifest.surfaces.
|
|
823
|
+
it('E4 (Codex P2): --surfaces cursor lowers only cursor surfaces; .claude/.codex untouched', async () => {
|
|
824
|
+
await setupMocks();
|
|
825
|
+
mkdirSync(join(e2eRoot, '.cursor', 'skills', 'Foo'), { recursive: true });
|
|
826
|
+
mkdirSync(join(e2eRoot, '.claude', 'rules'), { recursive: true });
|
|
827
|
+
const cursorSkillPath = join(e2eRoot, '.cursor', 'skills', 'Foo', 'SKILL.md');
|
|
828
|
+
const codexSkillPath = join(e2eRoot, '.codex', 'skills', 'Foo.md');
|
|
829
|
+
const cursorRulePath = join(e2eRoot, '.cursor', 'rules', 'Foo.mdc');
|
|
830
|
+
const claudeRulePath = join(e2eRoot, '.claude', 'rules', 'Foo.md');
|
|
831
|
+
const marked = (label) => `<!-- auto-generated by pb handshake -->\n# Foo ${label}\n`;
|
|
832
|
+
writeFileSync(cursorSkillPath, marked('cursor-skill'));
|
|
833
|
+
writeFileSync(codexSkillPath, marked('codex-skill'));
|
|
834
|
+
writeFileSync(cursorRulePath, marked('cursor-rule'));
|
|
835
|
+
writeFileSync(claudeRulePath, marked('claude-rule'));
|
|
836
|
+
const { runHandshake } = await import('../commands/handshake.js');
|
|
837
|
+
await runHandshake({ apply: true, surfaces: ['cursor'] });
|
|
838
|
+
// ── cursor surfaces lowered to .dormant
|
|
839
|
+
expect(existsSync(`${cursorSkillPath}.dormant`), 'cursor skill must be lowered').toBe(true);
|
|
840
|
+
expect(existsSync(`${cursorRulePath}.dormant`), 'cursor rule must be lowered').toBe(true);
|
|
841
|
+
expect(existsSync(cursorSkillPath), 'cursor skill original gone').toBe(false);
|
|
842
|
+
expect(existsSync(cursorRulePath), 'cursor rule original gone').toBe(false);
|
|
843
|
+
// ── non-requested surfaces left fully untouched (no .dormant, originals intact)
|
|
844
|
+
expect(existsSync(`${codexSkillPath}.dormant`), 'codex must NOT be lowered (--surfaces cursor)').toBe(false);
|
|
845
|
+
expect(existsSync(`${claudeRulePath}.dormant`), 'claude must NOT be lowered (--surfaces cursor)').toBe(false);
|
|
846
|
+
expect(existsSync(codexSkillPath), 'codex skill original intact').toBe(true);
|
|
847
|
+
expect(existsSync(claudeRulePath), 'claude rule original intact').toBe(true);
|
|
848
|
+
});
|
|
849
|
+
});
|
|
850
|
+
// ── WP-426 E4: raise — orphan .dormant removed after re-projection; drift on edited copy ─
|
|
851
|
+
//
|
|
852
|
+
// Drives runHandshake() with a kernel stub. The raise-cleanup pass runs in the
|
|
853
|
+
// `if (writeMode)` block after the dormant pass. It iterates over active assets,
|
|
854
|
+
// calls deriveDormantFilePaths, and runs restoreSurfaceFromDormant(filePath).
|
|
855
|
+
//
|
|
856
|
+
// restoreSurfaceFromDormant compares the EXISTING fresh projection on disk against
|
|
857
|
+
// the .dormant copy. In the real flow, the write loop re-projects the surface fresh
|
|
858
|
+
// before the raise pass runs. In the test, we pre-place the fresh .md to simulate
|
|
859
|
+
// what the write loop does (since readCanonicalSkills returns [] in these tests, the
|
|
860
|
+
// write loop doesn't write .codex/skills/*.md via the codex skill loop).
|
|
861
|
+
//
|
|
862
|
+
// Test structure:
|
|
863
|
+
// 1. Two-pass roundtrip: lower via runHandshake (pass 1) → pre-place fresh .md →
|
|
864
|
+
// raise via runHandshake (pass 2). Assert .md exists, .dormant gone.
|
|
865
|
+
// 2. Edited variant: pre-place edited .dormant + fresh .md → runHandshake →
|
|
866
|
+
// assert .dormant preserved + drift TEN queued.
|
|
867
|
+
// 3. Both-registries prune: pre-place with path in both dormantRenamed AND
|
|
868
|
+
// dormantReactivated → raise → assert both pruned.
|
|
869
|
+
describe('WP-426 E4: raise — orphan .dormant removed, drift on edited copy, both registries pruned', () => {
|
|
870
|
+
let e2eRoot;
|
|
871
|
+
let cwdSpy;
|
|
872
|
+
let kernelCallWithSessionMock;
|
|
873
|
+
// ── Shared mock setup factory — mirrors the E4 lowering pattern ──────────
|
|
874
|
+
async function setupRaiseMocks(opts = {}) {
|
|
875
|
+
vi.resetModules();
|
|
876
|
+
e2eRoot = mkdtempSync(join(tmpdir(), 'pb-wp426-e4-raise-'));
|
|
877
|
+
mkdirSync(join(e2eRoot, '.productbrain', 'skills'), { recursive: true });
|
|
878
|
+
mkdirSync(join(e2eRoot, '.codex', 'skills'), { recursive: true });
|
|
879
|
+
mkdirSync(join(e2eRoot, '.cursor', 'rules'), { recursive: true });
|
|
880
|
+
mkdirSync(join(e2eRoot, '.cursor', 'skills', 'Foo'), { recursive: true });
|
|
881
|
+
mkdirSync(join(e2eRoot, '.claude', 'rules'), { recursive: true });
|
|
882
|
+
// Real manifest — mode='project' so writeMode is active
|
|
883
|
+
writeFileSync(join(e2eRoot, '.productbrain', 'manifest.yaml'), ['version: "0.1"', 'materialize: project', 'surfaces:', ' - .codex', ' - .cursor', ' - .claude', ''].join('\n'));
|
|
884
|
+
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(e2eRoot);
|
|
885
|
+
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
886
|
+
kernelCallWithSessionMock = vi.fn().mockResolvedValue(null);
|
|
887
|
+
vi.doMock('../lib/client.js', () => ({
|
|
888
|
+
kernelCall: vi.fn().mockImplementation(async (tool) => {
|
|
889
|
+
if (tool === 'chain.workspaceReadiness')
|
|
890
|
+
return null;
|
|
891
|
+
if (tool === 'chain.getOrientView')
|
|
892
|
+
return null;
|
|
893
|
+
if (tool === 'chain.searchEntries')
|
|
894
|
+
return [];
|
|
895
|
+
if (tool === 'setup.listAssetsForUser') {
|
|
896
|
+
return {
|
|
897
|
+
activeAssets: opts.activeAssets ?? [],
|
|
898
|
+
dormantAssets: opts.dormantAssets ?? [],
|
|
899
|
+
hasAnyReceipt: true,
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
if (tool === 'setup.updateLastProjectedHash')
|
|
903
|
+
return { ok: true };
|
|
904
|
+
if (tool === 'setup.getCurrentSetupState')
|
|
905
|
+
return { effectiveMode: 'project' };
|
|
906
|
+
if (tool === 'setup.materializeSetup')
|
|
907
|
+
return { ok: true, assetCount: 0, receiptWritten: true };
|
|
908
|
+
return null;
|
|
909
|
+
}),
|
|
910
|
+
kernelCallWithSession: kernelCallWithSessionMock,
|
|
911
|
+
}));
|
|
912
|
+
vi.doMock('../lib/config.js', () => ({
|
|
913
|
+
getConfigOrGuide: vi.fn().mockResolvedValue({ apiKey: 'pb_sk_e2e_test', siteUrl: 'https://test.convex.site' }),
|
|
914
|
+
}));
|
|
915
|
+
vi.doMock('../lib/repo-detect.js', () => ({
|
|
916
|
+
detectRepo: vi.fn().mockReturnValue({ name: 'e2e-test-repo', detectedStack: [], repoSlug: null }),
|
|
917
|
+
extractWorkspaceProfile: vi.fn().mockReturnValue(null),
|
|
918
|
+
}));
|
|
919
|
+
vi.doMock('../generators/adapters.js', () => ({
|
|
920
|
+
MARKER: 'auto-generated by pb handshake',
|
|
921
|
+
generateAgentsMd: vi.fn().mockReturnValue('<!-- auto-generated by pb handshake -->\nagents'),
|
|
922
|
+
generateClaudeMd: vi.fn().mockReturnValue('<!-- auto-generated by pb handshake -->\nclaude'),
|
|
923
|
+
generateCursorMdc: vi.fn().mockReturnValue('<!-- auto-generated by pb handshake -->\ncursor'),
|
|
924
|
+
generateCopilotMd: vi.fn().mockReturnValue('<!-- auto-generated by pb handshake -->\ncopilot'),
|
|
925
|
+
}));
|
|
926
|
+
vi.doMock('../generators/portable-knowledge.js', () => ({
|
|
927
|
+
readCanonicalSkills: vi.fn().mockReturnValue([]),
|
|
928
|
+
readCanonicalRules: vi.fn().mockReturnValue([]),
|
|
929
|
+
readPersonalLayer: vi.fn().mockReturnValue([]),
|
|
930
|
+
readPersonalSkillsLayer: vi.fn().mockReturnValue([]),
|
|
931
|
+
generateCursorSkill: vi.fn().mockReturnValue(''),
|
|
932
|
+
generateCursorRule: vi.fn().mockReturnValue(''),
|
|
933
|
+
generateCodexSkill: vi.fn().mockReturnValue(''),
|
|
934
|
+
generateClaudeRule: vi.fn().mockReturnValue(''),
|
|
935
|
+
generateCodexSkillIndex: vi.fn().mockReturnValue(''),
|
|
936
|
+
generateClaudeSkillRouter: vi.fn().mockReturnValue(''),
|
|
937
|
+
shouldEmitToTarget: vi.fn().mockReturnValue(false),
|
|
938
|
+
filterByLevel: vi.fn().mockImplementation((items) => items),
|
|
939
|
+
evaluateConditions: vi.fn().mockReturnValue({ included: true, reasons: [] }),
|
|
940
|
+
validateCodexSkills: vi.fn().mockReturnValue([]),
|
|
941
|
+
STAGE_TO_MAX_LEVEL: {},
|
|
942
|
+
LEVEL_ORDER: [],
|
|
943
|
+
}));
|
|
944
|
+
vi.doMock('../generators/briefing-md.js', () => ({ generateBriefingMd: vi.fn().mockReturnValue('briefing') }));
|
|
945
|
+
vi.doMock('../generators/context-md.js', () => ({ generateContextMd: vi.fn().mockReturnValue('context') }));
|
|
946
|
+
vi.doMock('../generators/chain-rules.js', () => ({
|
|
947
|
+
generateChainRules: vi.fn().mockResolvedValue({ content: '', rules: [], classified: {}, stats: { generatedRules: 0, suppressedByManual: 0, suppressedByZeroEntries: 0 }, gaps: [], sentinel: null }),
|
|
948
|
+
}));
|
|
949
|
+
vi.doMock('../generators/handshake-diff.js', () => ({
|
|
950
|
+
saveHandshakeState: vi.fn(),
|
|
951
|
+
loadPreviousState: vi.fn().mockReturnValue(null),
|
|
952
|
+
diffHandshakeState: vi.fn().mockReturnValue({ added: [], updated: [], removed: [] }),
|
|
953
|
+
formatDiff: vi.fn().mockReturnValue(''),
|
|
954
|
+
buildCurrentState: vi.fn().mockReturnValue({}),
|
|
955
|
+
}));
|
|
956
|
+
vi.doMock('../generators/surface-profiles.js', () => ({
|
|
957
|
+
resolveSurfaceProfile: vi.fn().mockReturnValue({ level: 'intermediate' }),
|
|
958
|
+
}));
|
|
959
|
+
vi.doMock('../generators/boundary-manifest.js', () => ({
|
|
960
|
+
generateBoundaryManifest: vi.fn().mockReturnValue(null),
|
|
961
|
+
getBoundaryEnforcementMode: vi.fn().mockReturnValue('advisory'),
|
|
962
|
+
}));
|
|
963
|
+
vi.doMock('../formatters/handshake.js', () => ({ formatHandshakeReport: vi.fn().mockReturnValue('') }));
|
|
964
|
+
vi.doMock('../lib/hook-intents.js', () => ({
|
|
965
|
+
composeHooksFromIntents: vi.fn().mockReturnValue({}),
|
|
966
|
+
getHookStatusForSurface: vi.fn().mockReturnValue([]),
|
|
967
|
+
}));
|
|
968
|
+
vi.doMock('../lib/method-registry.js', () => ({
|
|
969
|
+
loadMethodRegistry: vi.fn().mockResolvedValue({ methods: [], source: 'bundled', stale: false }),
|
|
970
|
+
}));
|
|
971
|
+
vi.doMock('../lib/workspaceVocabCache.js', () => ({ getOrFetchVocabCtx: vi.fn().mockResolvedValue(null) }));
|
|
972
|
+
vi.doMock('../lib/telemetry.js', () => ({ trackEvent: vi.fn() }));
|
|
973
|
+
vi.doMock('../lib/session.js', () => ({
|
|
974
|
+
readSession: vi.fn().mockReturnValue({ sessionId: 'test-session-id', userId: 'test-user' }),
|
|
975
|
+
}));
|
|
976
|
+
vi.doMock('../lib/canonicalRefs.js', () => ({
|
|
977
|
+
parseSemanticRefs: vi.fn().mockReturnValue([]),
|
|
978
|
+
replaceVocabTokens: vi.fn().mockImplementation((content) => content),
|
|
979
|
+
}));
|
|
980
|
+
vi.doMock('../surfaces/telemetry.js', () => ({
|
|
981
|
+
getReverseMapFallbackMessage: vi.fn().mockReturnValue(''),
|
|
982
|
+
reportReverseMapMissing: vi.fn(),
|
|
983
|
+
}));
|
|
984
|
+
}
|
|
985
|
+
afterEach(() => {
|
|
986
|
+
cwdSpy?.mockRestore();
|
|
987
|
+
if (e2eRoot && existsSync(e2eRoot))
|
|
988
|
+
rmSync(e2eRoot, { recursive: true, force: true });
|
|
989
|
+
vi.restoreAllMocks();
|
|
990
|
+
});
|
|
991
|
+
it('E4: raise roundtrip — lower via runHandshake, then raise via runHandshake; .md exists, .dormant gone, registries pruned', async () => {
|
|
992
|
+
// ── Pass 1: lower — asset in dormantAssets → .dormant rename
|
|
993
|
+
await setupRaiseMocks({
|
|
994
|
+
dormantAssets: [{
|
|
995
|
+
entryId: 'SETUP-SKILL-FOO',
|
|
996
|
+
name: 'Foo',
|
|
997
|
+
description: 'A skill',
|
|
998
|
+
body: '# Foo\nSkill body.',
|
|
999
|
+
triggers: [],
|
|
1000
|
+
assetKind: 'skill',
|
|
1001
|
+
semanticRefs: [],
|
|
1002
|
+
disabledByOwner: false,
|
|
1003
|
+
lastProjectedHash: null,
|
|
1004
|
+
}],
|
|
1005
|
+
activeAssets: [],
|
|
1006
|
+
});
|
|
1007
|
+
const codexSkillPath = join(e2eRoot, '.codex', 'skills', 'Foo.md');
|
|
1008
|
+
const syncPath = join(e2eRoot, '.productbrain', '.authoring-sync.json');
|
|
1009
|
+
// Pre-place the surface file with MARKER so the lowering pass can rename it
|
|
1010
|
+
writeFileSync(codexSkillPath, '<!-- auto-generated by pb handshake -->\n# Foo\nSkill body.\n');
|
|
1011
|
+
const { runHandshake: runLower } = await import('../commands/handshake.js');
|
|
1012
|
+
await runLower({ apply: true });
|
|
1013
|
+
// Verify lowering worked: .dormant exists, original gone, registry updated
|
|
1014
|
+
expect(existsSync(`${codexSkillPath}.dormant`), 'after lower: .dormant must exist').toBe(true);
|
|
1015
|
+
expect(existsSync(codexSkillPath), 'after lower: original must be gone').toBe(false);
|
|
1016
|
+
const syncAfterLower = JSON.parse(readFileSync(syncPath, 'utf8'));
|
|
1017
|
+
expect(syncAfterLower.dormantRenamed).toContain('.codex/skills/Foo.md');
|
|
1018
|
+
// ── Pass 2: raise — asset back in activeAssets; fresh .md pre-placed to simulate write loop
|
|
1019
|
+
vi.resetModules();
|
|
1020
|
+
const savedRoot = e2eRoot; // preserve before setupRaiseMocks might reset
|
|
1021
|
+
await setupRaiseMocks({
|
|
1022
|
+
activeAssets: [{
|
|
1023
|
+
entryId: 'SETUP-SKILL-FOO',
|
|
1024
|
+
name: 'Foo',
|
|
1025
|
+
description: 'A skill',
|
|
1026
|
+
body: '# Foo\nSkill body.',
|
|
1027
|
+
triggers: [],
|
|
1028
|
+
assetKind: 'skill',
|
|
1029
|
+
semanticRefs: [],
|
|
1030
|
+
disabledByOwner: false,
|
|
1031
|
+
lastProjectedHash: null,
|
|
1032
|
+
scope: 'personal',
|
|
1033
|
+
}],
|
|
1034
|
+
dormantAssets: [],
|
|
1035
|
+
});
|
|
1036
|
+
// setupRaiseMocks creates a new e2eRoot — restore the saved root so we test
|
|
1037
|
+
// against the lowered FS state from pass 1.
|
|
1038
|
+
rmSync(e2eRoot, { recursive: true, force: true });
|
|
1039
|
+
e2eRoot = savedRoot;
|
|
1040
|
+
cwdSpy.mockReturnValue(e2eRoot);
|
|
1041
|
+
// Simulate what the write loop does: re-project the surface fresh.
|
|
1042
|
+
// (The codex skill loop doesn't write .codex/skills/*.md in test env because
|
|
1043
|
+
// readCanonicalSkills returns []; pre-placing it models the real write-loop output.)
|
|
1044
|
+
writeFileSync(codexSkillPath, '<!-- auto-generated by pb handshake -->\n# Foo\nSkill body.\n');
|
|
1045
|
+
const { runHandshake: runRaise } = await import('../commands/handshake.js');
|
|
1046
|
+
await runRaise({ apply: true });
|
|
1047
|
+
// ── .md exists, .dormant gone
|
|
1048
|
+
expect(existsSync(codexSkillPath), 'after raise: .codex/skills/Foo.md must exist').toBe(true);
|
|
1049
|
+
expect(existsSync(`${codexSkillPath}.dormant`), 'after raise: .dormant must be gone').toBe(false);
|
|
1050
|
+
// ── dormantRenamed pruned
|
|
1051
|
+
const syncAfterRaise = JSON.parse(readFileSync(syncPath, 'utf8'));
|
|
1052
|
+
expect(syncAfterRaise.dormantRenamed ?? [], 'dormantRenamed must not contain the raised path').not.toContain('.codex/skills/Foo.md');
|
|
1053
|
+
expect(syncAfterRaise.dormantReactivated ?? [], 'dormantReactivated must not contain the raised path').not.toContain('.codex/skills/Foo.md');
|
|
1054
|
+
});
|
|
1055
|
+
it('E4: raise with edited .dormant — .dormant preserved and drift TEN queued', async () => {
|
|
1056
|
+
// Pre-place state: fresh .md + edited .dormant (bodies differ) → orphan-drift.
|
|
1057
|
+
await setupRaiseMocks({
|
|
1058
|
+
activeAssets: [{
|
|
1059
|
+
entryId: 'SETUP-SKILL-FOO',
|
|
1060
|
+
name: 'Foo',
|
|
1061
|
+
description: 'A skill',
|
|
1062
|
+
body: '# Foo\nSkill body.',
|
|
1063
|
+
triggers: [],
|
|
1064
|
+
assetKind: 'skill',
|
|
1065
|
+
semanticRefs: [],
|
|
1066
|
+
disabledByOwner: false,
|
|
1067
|
+
lastProjectedHash: null,
|
|
1068
|
+
scope: 'personal',
|
|
1069
|
+
}],
|
|
1070
|
+
dormantAssets: [],
|
|
1071
|
+
});
|
|
1072
|
+
const codexSkillPath = join(e2eRoot, '.codex', 'skills', 'Foo.md');
|
|
1073
|
+
const syncPath = join(e2eRoot, '.productbrain', '.authoring-sync.json');
|
|
1074
|
+
// Fresh .md (simulates write loop output)
|
|
1075
|
+
writeFileSync(codexSkillPath, '<!-- auto-generated by pb handshake -->\n# Foo\nSkill body.\n');
|
|
1076
|
+
// Edited .dormant — body differs from fresh → restoreSurfaceFromDormant returns 'orphan-drift'
|
|
1077
|
+
writeFileSync(`${codexSkillPath}.dormant`, '<!-- auto-generated by pb handshake -->\n# Foo\nUSER EDITED CONTENT HERE.\n<!-- pb-status: dormant -->\n');
|
|
1078
|
+
// Registry shows this path was previously lowered
|
|
1079
|
+
writeFileSync(syncPath, JSON.stringify({
|
|
1080
|
+
version: 1, assets: {}, dormantRenamed: ['.codex/skills/Foo.md'], dormantReactivated: [],
|
|
1081
|
+
}, null, 2) + '\n');
|
|
1082
|
+
const { runHandshake } = await import('../commands/handshake.js');
|
|
1083
|
+
await runHandshake({ apply: true });
|
|
1084
|
+
// .md still exists (write loop placed it; raise-cleanup doesn't remove it)
|
|
1085
|
+
expect(existsSync(codexSkillPath), 'edited variant: .md must exist').toBe(true);
|
|
1086
|
+
// .dormant preserved (body differs → orphan-drift)
|
|
1087
|
+
expect(existsSync(`${codexSkillPath}.dormant`), 'edited variant: edited .dormant must be preserved').toBe(true);
|
|
1088
|
+
// drift TEN queued
|
|
1089
|
+
const driftCalls = kernelCallWithSessionMock.mock.calls.filter((call) => {
|
|
1090
|
+
const args = call[1];
|
|
1091
|
+
return (call[0] === 'chain.createEntry' &&
|
|
1092
|
+
args.collectionSlug === 'tensions' &&
|
|
1093
|
+
typeof args.data?.description === 'string' &&
|
|
1094
|
+
args.data.description.includes('edited while dormant'));
|
|
1095
|
+
});
|
|
1096
|
+
expect(driftCalls.length, 'edited variant: drift TEN must be queued').toBeGreaterThan(0);
|
|
1097
|
+
});
|
|
1098
|
+
it('E4: raise also prunes dormantReactivated registry (carry-over obligation)', async () => {
|
|
1099
|
+
// Pre-place fresh .md + matching .dormant (bodies match) + path in BOTH registries.
|
|
1100
|
+
// After raise: .dormant removed, BOTH entries pruned.
|
|
1101
|
+
await setupRaiseMocks({
|
|
1102
|
+
activeAssets: [{
|
|
1103
|
+
entryId: 'SETUP-SKILL-FOO',
|
|
1104
|
+
name: 'Foo',
|
|
1105
|
+
description: 'A skill',
|
|
1106
|
+
body: '# Foo\nSkill body.',
|
|
1107
|
+
triggers: [],
|
|
1108
|
+
assetKind: 'skill',
|
|
1109
|
+
semanticRefs: [],
|
|
1110
|
+
disabledByOwner: false,
|
|
1111
|
+
lastProjectedHash: null,
|
|
1112
|
+
scope: 'personal',
|
|
1113
|
+
}],
|
|
1114
|
+
dormantAssets: [],
|
|
1115
|
+
});
|
|
1116
|
+
const codexSkillPath = join(e2eRoot, '.codex', 'skills', 'Foo.md');
|
|
1117
|
+
const syncPath = join(e2eRoot, '.productbrain', '.authoring-sync.json');
|
|
1118
|
+
// Fresh .md (simulates write loop output)
|
|
1119
|
+
writeFileSync(codexSkillPath, '<!-- auto-generated by pb handshake -->\n# Foo\nSkill body.\n');
|
|
1120
|
+
// Matching .dormant (bodies match after stripping DORMANT_MARKER)
|
|
1121
|
+
writeFileSync(`${codexSkillPath}.dormant`, '<!-- auto-generated by pb handshake -->\n# Foo\nSkill body.\n<!-- pb-status: dormant -->\n');
|
|
1122
|
+
// Registry: path in BOTH dormantRenamed AND dormantReactivated
|
|
1123
|
+
writeFileSync(syncPath, JSON.stringify({
|
|
1124
|
+
version: 1, assets: {},
|
|
1125
|
+
dormantRenamed: ['.codex/skills/Foo.md'],
|
|
1126
|
+
dormantReactivated: ['.codex/skills/Foo.md'],
|
|
1127
|
+
}, null, 2) + '\n');
|
|
1128
|
+
const { runHandshake } = await import('../commands/handshake.js');
|
|
1129
|
+
await runHandshake({ apply: true });
|
|
1130
|
+
// .dormant removed (bodies match)
|
|
1131
|
+
expect(existsSync(`${codexSkillPath}.dormant`), 'carry-over: .dormant must be removed on raise').toBe(false);
|
|
1132
|
+
// BOTH registries pruned
|
|
1133
|
+
const syncAfterRaise = JSON.parse(readFileSync(syncPath, 'utf8'));
|
|
1134
|
+
expect(syncAfterRaise.dormantRenamed ?? [], 'carry-over: dormantRenamed must be pruned').not.toContain('.codex/skills/Foo.md');
|
|
1135
|
+
expect(syncAfterRaise.dormantReactivated ?? [], 'carry-over: dormantReactivated must be pruned').not.toContain('.codex/skills/Foo.md');
|
|
1136
|
+
});
|
|
1137
|
+
// Codex P1: registry pruning must happen for ANY active asset on raise — even when no
|
|
1138
|
+
// .dormant sibling exists this run. A surface that was manually reactivated earlier leaves
|
|
1139
|
+
// no .dormant (restoreSurfaceFromDormant → 'skipped'), but its path lingers in
|
|
1140
|
+
// dormantReactivated. The old code only pruned on 'restored', so the hands-off entry was
|
|
1141
|
+
// permanent and a future lowering could never re-dormant the surface. The fix prunes
|
|
1142
|
+
// unconditionally for active assets.
|
|
1143
|
+
it('E4 (Codex P1): raise prunes dormantReactivated even when no .dormant sibling exists', async () => {
|
|
1144
|
+
await setupRaiseMocks({
|
|
1145
|
+
activeAssets: [{
|
|
1146
|
+
entryId: 'SETUP-RULE-FOO',
|
|
1147
|
+
name: 'Foo',
|
|
1148
|
+
description: 'An active rule that was manually reactivated earlier (no .dormant left)',
|
|
1149
|
+
body: '# Foo\nRule body.',
|
|
1150
|
+
triggers: [],
|
|
1151
|
+
assetKind: 'rule',
|
|
1152
|
+
semanticRefs: [],
|
|
1153
|
+
disabledByOwner: false,
|
|
1154
|
+
lastProjectedHash: null,
|
|
1155
|
+
scope: 'personal',
|
|
1156
|
+
}],
|
|
1157
|
+
dormantAssets: [],
|
|
1158
|
+
});
|
|
1159
|
+
const cursorRulePath = join(e2eRoot, '.cursor', 'rules', 'Foo.mdc');
|
|
1160
|
+
const claudeRulePath = join(e2eRoot, '.claude', 'rules', 'Foo.md');
|
|
1161
|
+
const syncPath = join(e2eRoot, '.productbrain', '.authoring-sync.json');
|
|
1162
|
+
// Live files present (asset is active + re-projected), but NO .dormant siblings:
|
|
1163
|
+
// the user manually reactivated earlier, so restoreSurfaceFromDormant returns 'skipped'.
|
|
1164
|
+
writeFileSync(cursorRulePath, '<!-- auto-generated by pb handshake -->\n# Foo\nRule body.\n');
|
|
1165
|
+
writeFileSync(claudeRulePath, '<!-- auto-generated by pb handshake -->\n# Foo\nRule body.\n');
|
|
1166
|
+
expect(existsSync(`${cursorRulePath}.dormant`), 'setup: cursor .dormant must be absent').toBe(false);
|
|
1167
|
+
expect(existsSync(`${claudeRulePath}.dormant`), 'setup: claude .dormant must be absent').toBe(false);
|
|
1168
|
+
// Registry still lists both paths in the hands-off set from the earlier reactivation.
|
|
1169
|
+
writeFileSync(syncPath, JSON.stringify({
|
|
1170
|
+
version: 1, assets: {},
|
|
1171
|
+
dormantRenamed: [],
|
|
1172
|
+
dormantReactivated: ['.cursor/rules/Foo.mdc', '.claude/rules/Foo.md'],
|
|
1173
|
+
}, null, 2) + '\n');
|
|
1174
|
+
const { runHandshake } = await import('../commands/handshake.js');
|
|
1175
|
+
await runHandshake({ apply: true });
|
|
1176
|
+
// The hands-off entries must be pruned even though no .dormant existed this run,
|
|
1177
|
+
// so a future lowering can re-dormant the surface again.
|
|
1178
|
+
const syncAfterRaise = JSON.parse(readFileSync(syncPath, 'utf8'));
|
|
1179
|
+
expect(syncAfterRaise.dormantReactivated ?? [], 'cursor path must be pruned from dormantReactivated').not.toContain('.cursor/rules/Foo.mdc');
|
|
1180
|
+
expect(syncAfterRaise.dormantReactivated ?? [], 'claude path must be pruned from dormantReactivated').not.toContain('.claude/rules/Foo.md');
|
|
1181
|
+
});
|
|
1182
|
+
// WP-426 E4 Domain Expert fix: surface-filtered raise must NOT false-flag orphan-drift
|
|
1183
|
+
//
|
|
1184
|
+
// Simulates a raise where the cursor surface was NOT written this run (e.g. --surfaces codex).
|
|
1185
|
+
// The cursor .dormant file exists from a prior lowering, but the cursor live .md was never
|
|
1186
|
+
// written back by the write loop. The raise-cleanup pass must NOT treat the absent live file
|
|
1187
|
+
// as evidence of a user edit → must NOT queue an 'edited while dormant' drift TEN.
|
|
1188
|
+
//
|
|
1189
|
+
// Implementation: we do NOT pre-place the cursor live file, simulating a surface-filtered run
|
|
1190
|
+
// where the cursor loop was skipped. The codex surface IS written (pre-placed) so a full-surface
|
|
1191
|
+
// raise would succeed for codex; only cursor is left absent.
|
|
1192
|
+
it('E4: surface-filtered raise — absent live file skips, no false orphan-drift TEN, .dormant preserved', async () => {
|
|
1193
|
+
await setupRaiseMocks({
|
|
1194
|
+
activeAssets: [{
|
|
1195
|
+
entryId: 'SETUP-RULE-FOO',
|
|
1196
|
+
name: 'Foo',
|
|
1197
|
+
description: 'A rule that is active but cursor surface was not written this run',
|
|
1198
|
+
body: '# Foo\nRule body.',
|
|
1199
|
+
triggers: [],
|
|
1200
|
+
assetKind: 'rule',
|
|
1201
|
+
semanticRefs: [],
|
|
1202
|
+
disabledByOwner: false,
|
|
1203
|
+
lastProjectedHash: null,
|
|
1204
|
+
scope: 'personal',
|
|
1205
|
+
}],
|
|
1206
|
+
dormantAssets: [],
|
|
1207
|
+
});
|
|
1208
|
+
// Cursor rule path — corresponds to deriveDormantFilePaths for assetKind:'rule'
|
|
1209
|
+
const cursorRulePath = join(e2eRoot, '.cursor', 'rules', 'Foo.mdc');
|
|
1210
|
+
const claudeRulePath = join(e2eRoot, '.claude', 'rules', 'Foo.md');
|
|
1211
|
+
const syncPath = join(e2eRoot, '.productbrain', '.authoring-sync.json');
|
|
1212
|
+
// ── Pre-place state simulating a prior lowering that created .dormant files
|
|
1213
|
+
// for both cursor and claude, then a surface-filtered raise where neither
|
|
1214
|
+
// live file was written back by the write loop (both are absent).
|
|
1215
|
+
writeFileSync(`${cursorRulePath}.dormant`, '<!-- auto-generated by pb handshake -->\n# Foo\nRule body.\n<!-- pb-status: dormant -->\n');
|
|
1216
|
+
writeFileSync(`${claudeRulePath}.dormant`, '<!-- auto-generated by pb handshake -->\n# Foo\nRule body.\n<!-- pb-status: dormant -->\n');
|
|
1217
|
+
// Registry shows both paths were previously lowered
|
|
1218
|
+
writeFileSync(syncPath, JSON.stringify({
|
|
1219
|
+
version: 1, assets: {},
|
|
1220
|
+
dormantRenamed: ['.cursor/rules/Foo.mdc', '.claude/rules/Foo.md'],
|
|
1221
|
+
dormantReactivated: [],
|
|
1222
|
+
}, null, 2) + '\n');
|
|
1223
|
+
// Crucially: neither cursorRulePath nor claudeRulePath is pre-placed (surface-filtered run)
|
|
1224
|
+
expect(existsSync(cursorRulePath), 'setup: cursor live file must be absent').toBe(false);
|
|
1225
|
+
expect(existsSync(claudeRulePath), 'setup: claude live file must be absent').toBe(false);
|
|
1226
|
+
const { runHandshake } = await import('../commands/handshake.js');
|
|
1227
|
+
await runHandshake({ apply: true });
|
|
1228
|
+
// ── (a) .dormant files must still be present (not cleaned up for absent live files)
|
|
1229
|
+
expect(existsSync(`${cursorRulePath}.dormant`), 'surface-filtered: cursor .dormant must be preserved').toBe(true);
|
|
1230
|
+
expect(existsSync(`${claudeRulePath}.dormant`), 'surface-filtered: claude .dormant must be preserved').toBe(true);
|
|
1231
|
+
// ── (b) NO 'edited while dormant' drift TEN must have been queued
|
|
1232
|
+
const driftCalls = kernelCallWithSessionMock.mock.calls.filter((call) => {
|
|
1233
|
+
const args = call[1];
|
|
1234
|
+
return (call[0] === 'chain.createEntry' &&
|
|
1235
|
+
args.collectionSlug === 'tensions' &&
|
|
1236
|
+
typeof args.data?.description === 'string' &&
|
|
1237
|
+
args.data.description.includes('edited while dormant'));
|
|
1238
|
+
});
|
|
1239
|
+
expect(driftCalls.length, 'surface-filtered: no false orphan-drift TEN must be queued').toBe(0);
|
|
1240
|
+
// ── (c) no orphan-drift for the absent cursor / claude surface paths
|
|
1241
|
+
// (verified by the absence of drift TEN calls above; belt-and-suspenders check)
|
|
1242
|
+
expect(existsSync(cursorRulePath), 'surface-filtered: cursor live file must remain absent').toBe(false);
|
|
1243
|
+
expect(existsSync(claudeRulePath), 'surface-filtered: claude live file must remain absent').toBe(false);
|
|
1244
|
+
// ── (d) Codex P2: because the .dormant files still exist (restore skipped, no fresh
|
|
1245
|
+
// live file), the dormantRenamed registry evidence must be KEPT — not pruned — so a
|
|
1246
|
+
// later manual .dormant→live reactivation is still recognized as previouslyRenamed.
|
|
1247
|
+
const syncAfterRaise = JSON.parse(readFileSync(syncPath, 'utf8'));
|
|
1248
|
+
expect(syncAfterRaise.dormantRenamed ?? [], 'surface-filtered: cursor registry evidence must be kept').toContain('.cursor/rules/Foo.mdc');
|
|
1249
|
+
expect(syncAfterRaise.dormantRenamed ?? [], 'surface-filtered: claude registry evidence must be kept').toContain('.claude/rules/Foo.md');
|
|
1250
|
+
});
|
|
1251
|
+
});
|
|
1252
|
+
//# sourceMappingURL=handshake.e2e.test.js.map
|