@productbrain/cli 0.1.0-beta.106 → 0.1.0-beta.1065
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__/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 +255 -5
- 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 +133 -10
- 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__/profiles.test.js +106 -2
- 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-reset.test.d.ts +2 -0
- package/dist/__tests__/session-reset.test.d.ts.map +1 -0
- package/dist/__tests__/session-reset.test.js +122 -0
- package/dist/__tests__/session-reset.test.js.map +1 -0
- package/dist/__tests__/session-resume-backstop.test.d.ts +2 -0
- package/dist/__tests__/session-resume-backstop.test.d.ts.map +1 -0
- package/dist/__tests__/session-resume-backstop.test.js +97 -0
- package/dist/__tests__/session-resume-backstop.test.js.map +1 -0
- 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/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 +32 -2
- package/dist/commands/admin/seed.d.ts.map +1 -1
- package/dist/commands/admin/seed.js +279 -33
- package/dist/commands/admin/seed.js.map +1 -1
- package/dist/commands/admin/seed.test.js +7 -0
- 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 +7 -1
- package/dist/commands/authority-domains.d.ts.map +1 -1
- package/dist/commands/authority-domains.js +19 -4
- package/dist/commands/authority-domains.js.map +1 -1
- package/dist/commands/capture.d.ts +2 -0
- package/dist/commands/capture.d.ts.map +1 -1
- package/dist/commands/capture.js +8 -3
- package/dist/commands/capture.js.map +1 -1
- package/dist/commands/codex-prep.js +6 -6
- 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 +13 -12
- package/dist/commands/connect-integration.test.js.map +1 -1
- package/dist/commands/connect-screens.d.ts +7 -8
- package/dist/commands/connect-screens.d.ts.map +1 -1
- package/dist/commands/connect-screens.js +30 -37
- package/dist/commands/connect-screens.js.map +1 -1
- package/dist/commands/connect.d.ts +19 -6
- package/dist/commands/connect.d.ts.map +1 -1
- package/dist/commands/connect.js +31 -56
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +67 -2
- 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 +84 -4
- package/dist/commands/handshake.d.ts.map +1 -1
- package/dist/commands/handshake.js +1169 -97
- package/dist/commands/handshake.js.map +1 -1
- package/dist/commands/method.d.ts.map +1 -1
- package/dist/commands/method.js +5 -1
- package/dist/commands/method.js.map +1 -1
- package/dist/commands/orient.d.ts +52 -2
- package/dist/commands/orient.d.ts.map +1 -1
- package/dist/commands/orient.js +41 -7
- 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/reject.d.ts.map +1 -1
- package/dist/commands/reject.js +2 -1
- package/dist/commands/reject.js.map +1 -1
- package/dist/commands/relate.d.ts.map +1 -1
- package/dist/commands/relate.js +4 -2
- package/dist/commands/relate.js.map +1 -1
- package/dist/commands/session.d.ts +22 -0
- package/dist/commands/session.d.ts.map +1 -1
- package/dist/commands/session.js +112 -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/update.d.ts.map +1 -1
- package/dist/commands/update.js +2 -1
- package/dist/commands/update.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/verify.d.ts.map +1 -1
- package/dist/commands/verify.js +2 -1
- package/dist/commands/verify.js.map +1 -1
- 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/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 +47 -12
- 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/generators/adapters.js +4 -4
- 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 +2 -2
- package/dist/generators/portable-knowledge.d.ts.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 +144 -25
- package/dist/index.js.map +1 -1
- package/dist/lib/activation.js +2 -2
- 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 +13 -3
- 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/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 +1 -0
- package/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js +1 -0
- package/dist/lib/errors.js.map +1 -1
- 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 +26 -1
- 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
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
* pb handshake — generate context files for AI developer tools.
|
|
3
3
|
* Context export wiring (read-only filesystem bridge; GLO-63, DEC-161) — not a product surface.
|
|
4
4
|
*/
|
|
5
|
-
import { mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync, copyFileSync, appendFileSync, unlinkSync, statSync } from 'fs';
|
|
6
|
-
import { join, dirname, resolve, basename } from 'path';
|
|
5
|
+
import { mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync, copyFileSync, appendFileSync, unlinkSync, statSync, renameSync, rmSync, realpathSync } from 'fs';
|
|
6
|
+
import { join, dirname, resolve, basename, relative, sep, isAbsolute } from 'path';
|
|
7
7
|
import { homedir } from 'os';
|
|
8
8
|
import { fileURLToPath } from 'url';
|
|
9
9
|
import { createHash } from 'crypto';
|
|
10
10
|
import { getConfigOrGuide } from '../lib/config.js';
|
|
11
|
-
import { select as promptSelect } from '../lib/prompts.js';
|
|
11
|
+
import { select as promptSelect, confirm as promptConfirm } from '../lib/prompts.js';
|
|
12
12
|
import { composeHooksFromIntents, getHookStatusForSurface } from '../lib/hook-intents.js';
|
|
13
13
|
import { kernelCall, kernelCallWithSession } from '../lib/client.js';
|
|
14
14
|
import { readSession } from '../lib/session.js';
|
|
@@ -21,12 +21,21 @@ import { generateChainRules } from '../generators/chain-rules.js';
|
|
|
21
21
|
import { saveHandshakeState, loadPreviousState, diffHandshakeState, formatDiff, buildCurrentState, } from '../generators/handshake-diff.js';
|
|
22
22
|
import { resolveSurfaceProfile } from '../generators/surface-profiles.js';
|
|
23
23
|
import { formatHandshakeReport } from '../formatters/handshake.js';
|
|
24
|
-
import {
|
|
24
|
+
import { classifyAdapterFile, detectEol, spliceAppend, spliceReplace } from '../generators/region.js';
|
|
25
|
+
import { REGION_PROJECTIONS } from '../generators/region-projections.js';
|
|
26
|
+
import { readManifest, readManifestStatus, filterByAdoptionState } from '../generators/manifest.js';
|
|
25
27
|
import { generateBoundaryManifest, getBoundaryEnforcementMode } from '../generators/boundary-manifest.js';
|
|
26
28
|
import { loadMethodRegistry } from '../lib/method-registry.js';
|
|
27
29
|
import { CLIError, ErrorCode } from '../lib/errors.js';
|
|
28
30
|
import { trackEvent } from '../lib/telemetry.js';
|
|
31
|
+
import { replaceVocabTokens } from '../lib/canonicalRefs.js';
|
|
32
|
+
// WP-436 S3: vocab projector — resolves {{vocab:...}} tokens before writing to disk.
|
|
33
|
+
import { getOrFetchVocabCtx } from '../lib/workspaceVocabCache.js';
|
|
29
34
|
import { normalizeMaterializedFilename } from '../lib/normalizeMaterializedFilename.js';
|
|
35
|
+
import { assertSetupWritePath } from '../setup/perimeter.js';
|
|
36
|
+
// WP-421 S3: SurfaceAdapter reverse-map for tampered prompts (DEC-952, doneWhen #34).
|
|
37
|
+
import { canonicalPathForAnySurface, SURFACE_GOVERN_NO_SURFACES, SURFACE_REGISTRY, validateSurfacesForMode, } from '../surfaces/registry.js';
|
|
38
|
+
import { getReverseMapFallbackMessage, reportReverseMapMissing } from '../surfaces/telemetry.js';
|
|
30
39
|
const MAX_HANDSHAKE_WAIT_MS = 10_000; // 10 seconds
|
|
31
40
|
const POLL_INTERVAL_MS = 500; // 500 ms per poll
|
|
32
41
|
const MAX_POLLS = MAX_HANDSHAKE_WAIT_MS / POLL_INTERVAL_MS; // 20
|
|
@@ -82,6 +91,71 @@ export function writeDormantMarkerToFile(filePath) {
|
|
|
82
91
|
appendFileSync(filePath, `\n${DORMANT_MARKER}\n`);
|
|
83
92
|
return 'written';
|
|
84
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* renameSurfaceForDormancy — rename a projected surface file to `<path>.dormant`
|
|
96
|
+
* so external scanners (which glob *.md / *.mdc) stop loading it. WP-426 E4.
|
|
97
|
+
* The HTML-comment marker is invisible to scanners; the extension change removes
|
|
98
|
+
* the file from their glob set. Only touches files carrying the auto-gen MARKER.
|
|
99
|
+
* Runs on an INDEPENDENT existence check (not gated on marker-write result) so
|
|
100
|
+
* legacy WP-379 marker-only files still rename. Idempotent.
|
|
101
|
+
* Codex P1: when a .dormant already exists, only replace it if its body matches the
|
|
102
|
+
* live file. A divergent .dormant (user edited it) is preserved and reported as 'drift'
|
|
103
|
+
* rather than force-deleted — no silent data loss.
|
|
104
|
+
* @returns 'renamed' | 'replaced' | 'already-dormant' | 'skipped' | 'drift'
|
|
105
|
+
*/
|
|
106
|
+
export function renameSurfaceForDormancy(filePath) {
|
|
107
|
+
const dormantPath = `${filePath}.dormant`;
|
|
108
|
+
if (!existsSync(filePath))
|
|
109
|
+
return existsSync(dormantPath) ? 'already-dormant' : 'skipped';
|
|
110
|
+
if (!readFileSync(filePath, 'utf8').includes(MARKER))
|
|
111
|
+
return 'skipped';
|
|
112
|
+
const replacing = existsSync(dormantPath);
|
|
113
|
+
if (replacing) {
|
|
114
|
+
// Codex P1: the existing .dormant may carry user edits (e.g. the user reactivated to
|
|
115
|
+
// the live path but kept an edited dormant sibling). Compare normalized bodies
|
|
116
|
+
// (DORMANT_MARKER + volatile auto-gen timestamp stripped from both); if they diverge,
|
|
117
|
+
// preserve the .dormant and signal drift instead of force-replacing it.
|
|
118
|
+
const strip = (s) => normalizeHandshakeContentForComparison(s.split(DORMANT_MARKER).join('')).trimEnd();
|
|
119
|
+
if (strip(readFileSync(filePath, 'utf8')) !== strip(readFileSync(dormantPath, 'utf8'))) {
|
|
120
|
+
return 'drift';
|
|
121
|
+
}
|
|
122
|
+
rmSync(dormantPath, { force: true });
|
|
123
|
+
}
|
|
124
|
+
renameSync(filePath, dormantPath);
|
|
125
|
+
return replacing ? 'replaced' : 'renamed';
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* restoreSurfaceFromDormant — clean the orphan `<path>.dormant` sibling after a
|
|
129
|
+
* raised (observe→project) asset has been re-projected fresh from the DB body by
|
|
130
|
+
* the normal write loop. WP-426 E4.
|
|
131
|
+
* Normalizes the volatile auto-gen timestamp (via normalizeHandshakeContentForComparison)
|
|
132
|
+
* before comparing, else the lowering-time timestamp would always force a false
|
|
133
|
+
* 'orphan-drift'. Identical body → remove. Differs (user edited) → preserve + drift.
|
|
134
|
+
* @returns 'restored' | 'orphan-drift' | 'skipped'
|
|
135
|
+
*/
|
|
136
|
+
export function restoreSurfaceFromDormant(filePath) {
|
|
137
|
+
const dormantPath = `${filePath}.dormant`;
|
|
138
|
+
if (!existsSync(dormantPath))
|
|
139
|
+
return 'skipped';
|
|
140
|
+
// WP-426 E4: no fresh live projection this run (surface filtered / user-owned / not written) →
|
|
141
|
+
// nothing to reconcile; absence of fresh is NOT evidence of a user edit. Leave the .dormant.
|
|
142
|
+
if (!existsSync(filePath))
|
|
143
|
+
return 'skipped';
|
|
144
|
+
const fresh = readFileSync(filePath, 'utf8');
|
|
145
|
+
// Codex P2: only reconcile against a PB-managed projection. A live file WITHOUT the auto-gen
|
|
146
|
+
// MARKER is a user-owned file (shouldWriteAdapter would have skipped reprojecting it),
|
|
147
|
+
// so it was NOT raised this run — comparing the .dormant against it would wrongly delete it or
|
|
148
|
+
// re-fire "edited while dormant" drift. Leave the .dormant untouched.
|
|
149
|
+
if (!fresh.includes(MARKER))
|
|
150
|
+
return 'skipped';
|
|
151
|
+
const dormant = readFileSync(dormantPath, 'utf8').split(DORMANT_MARKER).join('');
|
|
152
|
+
const norm = (s) => normalizeHandshakeContentForComparison(s).trimEnd();
|
|
153
|
+
if (norm(dormant) === norm(fresh)) {
|
|
154
|
+
rmSync(dormantPath, { force: true });
|
|
155
|
+
return 'restored';
|
|
156
|
+
}
|
|
157
|
+
return 'orphan-drift';
|
|
158
|
+
}
|
|
85
159
|
/**
|
|
86
160
|
* deriveDormantFilePaths — compute the set of on-disk file paths that would have
|
|
87
161
|
* been projected for a given dormant asset (by name and assetKind).
|
|
@@ -93,7 +167,8 @@ export function writeDormantMarkerToFile(filePath) {
|
|
|
93
167
|
*
|
|
94
168
|
* @param asset The dormant asset from the server.
|
|
95
169
|
* @param cwd Current working directory (project root).
|
|
96
|
-
* @returns
|
|
170
|
+
* @returns Each candidate path paired with its owning surface, so callers can
|
|
171
|
+
* filter by the run's allowedTargets (--surfaces) and the perimeter.
|
|
97
172
|
*/
|
|
98
173
|
function deriveDormantFilePaths(asset, cwd) {
|
|
99
174
|
// Defense-in-depth: even though `name` originates from platform-seeded DB
|
|
@@ -103,26 +178,22 @@ function deriveDormantFilePaths(asset, cwd) {
|
|
|
103
178
|
if (!/^[A-Za-z0-9 ._-]+$/.test(asset.name)) {
|
|
104
179
|
return [];
|
|
105
180
|
}
|
|
106
|
-
const
|
|
181
|
+
const out = [];
|
|
107
182
|
const { name, assetKind } = asset;
|
|
108
183
|
if (assetKind === 'skill') {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
// Codex skill
|
|
112
|
-
paths.push(join(cwd, '.codex', 'skills', `${name}.md`));
|
|
184
|
+
out.push({ path: join(cwd, '.cursor', 'skills', name, 'SKILL.md'), surface: 'cursor' });
|
|
185
|
+
out.push({ path: join(cwd, '.codex', 'skills', `${name}.md`), surface: 'codex' });
|
|
113
186
|
}
|
|
114
187
|
else if (assetKind === 'rule' || assetKind === 'hook') {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
// Claude rule
|
|
118
|
-
paths.push(join(cwd, '.claude', 'rules', `${name}.md`));
|
|
188
|
+
out.push({ path: join(cwd, '.cursor', 'rules', `${name}.mdc`), surface: 'cursor' });
|
|
189
|
+
out.push({ path: join(cwd, '.claude', 'rules', `${name}.md`), surface: 'claude' });
|
|
119
190
|
}
|
|
120
|
-
return
|
|
191
|
+
return out;
|
|
121
192
|
}
|
|
122
193
|
/**
|
|
123
194
|
* Single-shot health probe — calls `workspace.health` and inspects
|
|
124
195
|
* `starterSetupSeeded`. Does NOT poll internally; polling is the caller's
|
|
125
|
-
* responsibility (connect-
|
|
196
|
+
* responsibility (connect-context.ts).
|
|
126
197
|
*
|
|
127
198
|
* Returns:
|
|
128
199
|
* - `seeds-ready` — health query succeeded AND starterSetupSeeded is true
|
|
@@ -158,7 +229,7 @@ export async function probeStarterSetupSeeded() {
|
|
|
158
229
|
* Poll `probeStarterSetupSeeded` up to MAX_POLLS times (10s at 500ms intervals).
|
|
159
230
|
* Returns the final probe result — caller decides how to render the outcome.
|
|
160
231
|
*
|
|
161
|
-
* Exported so connect-
|
|
232
|
+
* Exported so connect-context.ts can use it without re-implementing the loop.
|
|
162
233
|
*/
|
|
163
234
|
export async function pollUntilSeedsReady() {
|
|
164
235
|
for (let poll = 0; poll < MAX_POLLS; poll++) {
|
|
@@ -419,6 +490,256 @@ function shouldWriteAdapter(filePath, force) {
|
|
|
419
490
|
const content = readFileSync(filePath, 'utf8');
|
|
420
491
|
return content.includes(MARKER);
|
|
421
492
|
}
|
|
493
|
+
function normalizeSurfaceName(surface) {
|
|
494
|
+
const stripped = surface.startsWith('.') ? surface.slice(1) : surface;
|
|
495
|
+
const normalized = stripped === 'github' ? 'copilot' : stripped;
|
|
496
|
+
return normalized in SURFACE_REGISTRY ? normalized : null;
|
|
497
|
+
}
|
|
498
|
+
function surfacePerimeterRoots(surface) {
|
|
499
|
+
if (surface === 'codex')
|
|
500
|
+
return ['.codex', SURFACE_REGISTRY.codex.hookFilePath];
|
|
501
|
+
if (surface === 'copilot')
|
|
502
|
+
return ['.github', SURFACE_REGISTRY.copilot.hookFilePath];
|
|
503
|
+
if (surface === 'claude')
|
|
504
|
+
return ['.claude', 'CLAUDE.md'];
|
|
505
|
+
return [`.${surface}`];
|
|
506
|
+
}
|
|
507
|
+
function modeRank(mode) {
|
|
508
|
+
return { off: 0, observe: 1, project: 2, govern: 3 }[mode];
|
|
509
|
+
}
|
|
510
|
+
function normalizeSetupAuthoringBody(body) {
|
|
511
|
+
return body.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trimEnd();
|
|
512
|
+
}
|
|
513
|
+
function setupAuthoringAssetHash(asset) {
|
|
514
|
+
const canonical = {
|
|
515
|
+
entryId: asset.entryId,
|
|
516
|
+
name: asset.name,
|
|
517
|
+
description: asset.description ?? '',
|
|
518
|
+
assetKind: asset.assetKind,
|
|
519
|
+
triggers: asset.triggers ?? [],
|
|
520
|
+
semanticRefs: asset.semanticRefs ?? [],
|
|
521
|
+
body: normalizeSetupAuthoringBody(asset.body),
|
|
522
|
+
};
|
|
523
|
+
return `sha256:${createHash('sha256').update(JSON.stringify(canonical), 'utf8').digest('hex')}`;
|
|
524
|
+
}
|
|
525
|
+
function parseSetupAuthoringFrontmatter(raw) {
|
|
526
|
+
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
527
|
+
if (!fmMatch) {
|
|
528
|
+
const h1 = raw.match(/^# (.+)$/m);
|
|
529
|
+
return { name: h1?.[1] ?? '', description: '', body: raw, triggers: [], semanticRefs: [] };
|
|
530
|
+
}
|
|
531
|
+
const fields = new Map();
|
|
532
|
+
const arrayFields = new Map();
|
|
533
|
+
const lines = fmMatch[1].split('\n');
|
|
534
|
+
let currentArrayKey = null;
|
|
535
|
+
let currentArrayValues = [];
|
|
536
|
+
for (const line of lines) {
|
|
537
|
+
const arrayItemMatch = line.match(/^\s+-\s+(.+)$/);
|
|
538
|
+
const keyValueMatch = line.match(/^(\w+):\s*(.*)$/);
|
|
539
|
+
if (arrayItemMatch && currentArrayKey) {
|
|
540
|
+
currentArrayValues.push(arrayItemMatch[1].trim().replace(/^['"]|['"]$/g, ''));
|
|
541
|
+
}
|
|
542
|
+
else if (keyValueMatch) {
|
|
543
|
+
if (currentArrayKey) {
|
|
544
|
+
arrayFields.set(currentArrayKey, currentArrayValues);
|
|
545
|
+
currentArrayKey = null;
|
|
546
|
+
currentArrayValues = [];
|
|
547
|
+
}
|
|
548
|
+
const [, key, value] = keyValueMatch;
|
|
549
|
+
if (value.trim() === '') {
|
|
550
|
+
currentArrayKey = key;
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
fields.set(key, value.trim().replace(/^['"]|['"]$/g, ''));
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
if (currentArrayKey)
|
|
558
|
+
arrayFields.set(currentArrayKey, currentArrayValues);
|
|
559
|
+
const body = fmMatch[2];
|
|
560
|
+
return {
|
|
561
|
+
frontmatterId: fields.get('id'),
|
|
562
|
+
name: fields.get('name') ?? body.match(/^# (.+)$/m)?.[1] ?? '',
|
|
563
|
+
description: fields.get('description') ?? '',
|
|
564
|
+
body,
|
|
565
|
+
triggers: arrayFields.get('triggers') ?? [],
|
|
566
|
+
semanticRefs: arrayFields.get('semanticRefs') ?? [],
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
function deriveSetupAuthoringEntryId(filename, kind) {
|
|
570
|
+
const base = basename(filename, '.md');
|
|
571
|
+
const snakeCase = base.toUpperCase().replace(/[^A-Z0-9]+/g, '_');
|
|
572
|
+
return `SETUP-${kind.toUpperCase()}-${snakeCase}`;
|
|
573
|
+
}
|
|
574
|
+
function scanSetupAuthoringFiles(productbrainDir) {
|
|
575
|
+
const dirs = [
|
|
576
|
+
{ dir: 'skills', kind: 'skill' },
|
|
577
|
+
{ dir: 'rules', kind: 'rule' },
|
|
578
|
+
{ dir: 'hooks', kind: 'hook' },
|
|
579
|
+
];
|
|
580
|
+
const items = [];
|
|
581
|
+
for (const { dir, kind } of dirs) {
|
|
582
|
+
const absDir = join(productbrainDir, dir);
|
|
583
|
+
if (!existsSync(absDir))
|
|
584
|
+
continue;
|
|
585
|
+
for (const file of readdirSync(absDir).filter((f) => f.endsWith('.md'))) {
|
|
586
|
+
const filePath = join(absDir, file);
|
|
587
|
+
const parsed = parseSetupAuthoringFrontmatter(readFileSync(filePath, 'utf8'));
|
|
588
|
+
const fallbackName = basename(file, '.md');
|
|
589
|
+
items.push({
|
|
590
|
+
filePath,
|
|
591
|
+
derivedEntryId: deriveSetupAuthoringEntryId(file, kind),
|
|
592
|
+
frontmatterId: parsed.frontmatterId,
|
|
593
|
+
name: parsed.name || fallbackName,
|
|
594
|
+
description: parsed.description,
|
|
595
|
+
body: parsed.body,
|
|
596
|
+
assetKind: kind,
|
|
597
|
+
triggers: parsed.triggers,
|
|
598
|
+
semanticRefs: parsed.semanticRefs,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return items.sort((a, b) => a.filePath.localeCompare(b.filePath));
|
|
603
|
+
}
|
|
604
|
+
function setupAuthoringDirForKind(kind) {
|
|
605
|
+
if (kind === 'skill')
|
|
606
|
+
return 'skills';
|
|
607
|
+
if (kind === 'rule')
|
|
608
|
+
return 'rules';
|
|
609
|
+
if (kind === 'hook')
|
|
610
|
+
return 'hooks';
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
function setupAuthoringFilename(name, entryId) {
|
|
614
|
+
const base = (name || entryId)
|
|
615
|
+
.replace(/[\/\\:*?"<>|]/g, '-')
|
|
616
|
+
.replace(/\s+/g, ' ')
|
|
617
|
+
.trim();
|
|
618
|
+
return `${base || entryId}.md`;
|
|
619
|
+
}
|
|
620
|
+
function setupAuthoringPath(cwd, asset) {
|
|
621
|
+
const dir = setupAuthoringDirForKind(asset.assetKind);
|
|
622
|
+
if (!dir)
|
|
623
|
+
return null;
|
|
624
|
+
// WP-426 E3: recorded authoring source path wins so reprojection lands where the
|
|
625
|
+
// user authored — no duplicate (TEN-1920). Stored relative to .productbrain/.
|
|
626
|
+
if (asset.authoringPath && asset.authoringPath.trim()) {
|
|
627
|
+
// Codex P1/P2: authoringPath comes from the DB and is untrusted at projection
|
|
628
|
+
// time. It is probed via existsSync/readFileSync (and later writeFileSync) in the
|
|
629
|
+
// writeback loop BEFORE any assertSetupWritePath guard runs. Containment alone is
|
|
630
|
+
// not enough — a bad DB row could still point at a directory (e.g. "skills") or an
|
|
631
|
+
// internal PB file (".authoring-sync.json", "manifest.yaml"), which the loop would
|
|
632
|
+
// mis-handle as markdown (throw on a directory read, or overwrite PB state).
|
|
633
|
+
// Constrain to a kind-appropriate markdown file (<dir>/**/*.md); otherwise fall
|
|
634
|
+
// back to the safe name-derived path.
|
|
635
|
+
const pbDir = join(cwd, '.productbrain');
|
|
636
|
+
const candidate = resolve(pbDir, asset.authoringPath);
|
|
637
|
+
const within = relative(pbDir, candidate);
|
|
638
|
+
const withinPosix = within.split(sep).join('/');
|
|
639
|
+
if (!within.startsWith('..') &&
|
|
640
|
+
!isAbsolute(within) &&
|
|
641
|
+
withinPosix.startsWith(`${dir}/`) &&
|
|
642
|
+
withinPosix.toLowerCase().endsWith('.md')) {
|
|
643
|
+
return candidate;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return join(cwd, '.productbrain', dir, setupAuthoringFilename(asset.name, asset.entryId));
|
|
647
|
+
}
|
|
648
|
+
function renderSetupAuthoringFile(asset) {
|
|
649
|
+
const lines = [
|
|
650
|
+
'---',
|
|
651
|
+
`id: ${asset.entryId}`,
|
|
652
|
+
`name: ${JSON.stringify(asset.name)}`,
|
|
653
|
+
`description: ${JSON.stringify(asset.description ?? '')}`,
|
|
654
|
+
`assetKind: ${asset.assetKind}`,
|
|
655
|
+
];
|
|
656
|
+
const pushArray = (key, values) => {
|
|
657
|
+
if (!values || values.length === 0)
|
|
658
|
+
return;
|
|
659
|
+
lines.push(`${key}:`);
|
|
660
|
+
for (const value of values)
|
|
661
|
+
lines.push(` - ${JSON.stringify(value)}`);
|
|
662
|
+
};
|
|
663
|
+
pushArray('triggers', asset.triggers);
|
|
664
|
+
pushArray('semanticRefs', asset.semanticRefs);
|
|
665
|
+
lines.push('---', normalizeSetupAuthoringBody(asset.body), '');
|
|
666
|
+
return lines.join('\n');
|
|
667
|
+
}
|
|
668
|
+
function loadAuthoringSyncState(productbrainDir) {
|
|
669
|
+
const statePath = join(productbrainDir, '.authoring-sync.json');
|
|
670
|
+
if (!existsSync(statePath))
|
|
671
|
+
return { version: 1, assets: {} };
|
|
672
|
+
try {
|
|
673
|
+
const parsed = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
674
|
+
if (parsed.version !== 1 || !parsed.assets || typeof parsed.assets !== 'object') {
|
|
675
|
+
return { version: 1, assets: {} };
|
|
676
|
+
}
|
|
677
|
+
// Codex P2: the dormant registries are consumed via .includes() in the dormant loop
|
|
678
|
+
// BEFORE its per-file try/catch, so a malformed .authoring-sync.json (a field set to
|
|
679
|
+
// an object/number instead of an array) would throw a TypeError and abort the whole
|
|
680
|
+
// handshake. Coerce to a string[] (mirrors the assets validation above) to fail open.
|
|
681
|
+
const toStringArray = (v) => Array.isArray(v) ? v.filter((x) => typeof x === 'string') : [];
|
|
682
|
+
return {
|
|
683
|
+
version: 1,
|
|
684
|
+
assets: parsed.assets,
|
|
685
|
+
dormantRenamed: toStringArray(parsed.dormantRenamed),
|
|
686
|
+
dormantReactivated: toStringArray(parsed.dormantReactivated),
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
catch {
|
|
690
|
+
return { version: 1, assets: {} };
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
function saveAuthoringSyncState(productbrainDir, state) {
|
|
694
|
+
const statePath = join(productbrainDir, '.authoring-sync.json');
|
|
695
|
+
assertSetupWritePath(statePath, { surfaces: [] });
|
|
696
|
+
mkdirSync(productbrainDir, { recursive: true });
|
|
697
|
+
writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n');
|
|
698
|
+
}
|
|
699
|
+
const DRIFT_HASH_TRAILER_REGEX = /^<!--\s*pb-hash:\s*sha256:([0-9a-f]+)\s*-->\s*$/m;
|
|
700
|
+
const DRIFT_HASH_TRAILER_STRIP = /^<!--\s*pb-hash:.*-->\s*$/gm;
|
|
701
|
+
const DRIFT_TIMESTAMP_STRIP = /^<!--\s*pb-generated-at:.*-->\s*$/gm;
|
|
702
|
+
/**
|
|
703
|
+
* Classify a single projection-target file into one of the three drift buckets.
|
|
704
|
+
*
|
|
705
|
+
* Returns `null` when `filePath` does not exist (first-run / unprojected) — the
|
|
706
|
+
* write loop treats that as "would-write" and the file is not part of any bucket.
|
|
707
|
+
*
|
|
708
|
+
* @param filePath Absolute path to the projection file on disk.
|
|
709
|
+
* @returns { bucket, expectedHash, actualHash } when the file exists.
|
|
710
|
+
* `expectedHash`/`actualHash` are populated only for the
|
|
711
|
+
* tampered bucket so the headless refusal payload can include
|
|
712
|
+
* them verbatim. For clean / user-owned, both are `''`.
|
|
713
|
+
*/
|
|
714
|
+
export function classifyDriftBucket(filePath) {
|
|
715
|
+
if (!existsSync(filePath))
|
|
716
|
+
return null;
|
|
717
|
+
const content = readFileSync(filePath, 'utf8');
|
|
718
|
+
// No auto-gen MARKER → user-owned. Never touch.
|
|
719
|
+
if (!content.includes(MARKER)) {
|
|
720
|
+
return { bucket: 'user-owned', expectedHash: '', actualHash: '' };
|
|
721
|
+
}
|
|
722
|
+
// Marker present but no hash trailer → legacy / pre-S0c projection: treat as
|
|
723
|
+
// clean (the hash trailer was added in WP-345 S0c). The user-owned-vs-clean
|
|
724
|
+
// semantic falls back to existing shouldWriteAdapter behavior.
|
|
725
|
+
const trailerMatch = content.match(DRIFT_HASH_TRAILER_REGEX);
|
|
726
|
+
if (!trailerMatch) {
|
|
727
|
+
return { bucket: 'pb-managed-clean', expectedHash: '', actualHash: '' };
|
|
728
|
+
}
|
|
729
|
+
const expectedHash = `sha256:${trailerMatch[1]}`;
|
|
730
|
+
// Recompute the actual hash from the body (strip trailer + timestamp, LF, trim).
|
|
731
|
+
const normalized = content
|
|
732
|
+
.replace(DRIFT_HASH_TRAILER_STRIP, '')
|
|
733
|
+
.replace(DRIFT_TIMESTAMP_STRIP, '')
|
|
734
|
+
.replace(/\r\n/g, '\n')
|
|
735
|
+
.replace(/\r/g, '\n')
|
|
736
|
+
.trimEnd();
|
|
737
|
+
const actualHash = `sha256:${createHash('sha256').update(normalized, 'utf8').digest('hex')}`;
|
|
738
|
+
if (actualHash === expectedHash) {
|
|
739
|
+
return { bucket: 'pb-managed-clean', expectedHash, actualHash };
|
|
740
|
+
}
|
|
741
|
+
return { bucket: 'pb-managed-tampered', expectedHash, actualHash };
|
|
742
|
+
}
|
|
422
743
|
function deduplicateEntries(entries) {
|
|
423
744
|
const seen = new Set();
|
|
424
745
|
const result = [];
|
|
@@ -439,7 +760,7 @@ function deduplicateEntries(entries) {
|
|
|
439
760
|
* the auto-gen MARKER whose lowercase-normalized filename does NOT match any
|
|
440
761
|
* current active-asset materializedFilename, the file is unlinked.
|
|
441
762
|
*
|
|
442
|
-
* User-
|
|
763
|
+
* User-owned files (no MARKER) are never touched, regardless of name.
|
|
443
764
|
*
|
|
444
765
|
* Linux case-collision disambiguation:
|
|
445
766
|
* 1. Exact match (lowercase name == any canonical name): survives.
|
|
@@ -497,7 +818,7 @@ export function resolveProjectionCollision(cwd, assetNames, log, logErr) {
|
|
|
497
818
|
continue; // unreadable file — skip
|
|
498
819
|
}
|
|
499
820
|
if (!content.includes(MARKER))
|
|
500
|
-
continue; // user-
|
|
821
|
+
continue; // user-owned — never touch
|
|
501
822
|
const stem = basename(filename, ext);
|
|
502
823
|
const normalizedStem = normalizeMaterializedFilename(stem);
|
|
503
824
|
const group = groups.get(normalizedStem) ?? [];
|
|
@@ -590,9 +911,9 @@ export function resolveProjectionCollision(cwd, assetNames, log, logErr) {
|
|
|
590
911
|
return { results, collisionTens };
|
|
591
912
|
}
|
|
592
913
|
export async function runHandshake(options = {}) {
|
|
593
|
-
const config = await getConfigOrGuide(() => runHandshake(options));
|
|
914
|
+
const config = await getConfigOrGuide(async () => { await runHandshake(options); });
|
|
594
915
|
if (!config)
|
|
595
|
-
return;
|
|
916
|
+
return undefined;
|
|
596
917
|
const cwd = process.cwd();
|
|
597
918
|
const force = options.force ?? false;
|
|
598
919
|
const dryRun = options.dryRun ?? false;
|
|
@@ -672,16 +993,130 @@ export async function runHandshake(options = {}) {
|
|
|
672
993
|
// Primary: query setup.listAssetsForUser from DB (workspace SSOT).
|
|
673
994
|
// Fallback: read from .productbrain/ filesystem (legacy — used when DB is empty or unavailable).
|
|
674
995
|
const pbDir = join(cwd, '.productbrain');
|
|
996
|
+
const manifestStatus = readManifestStatus(pbDir);
|
|
997
|
+
const manifest = manifestStatus.manifest;
|
|
998
|
+
const surfaceValidation = validateSurfacesForMode(manifestStatus.mode, manifestStatus.surfaces);
|
|
999
|
+
if (applyMode && surfaceValidation.error === SURFACE_GOVERN_NO_SURFACES) {
|
|
1000
|
+
throw new CLIError('materialize: govern requires at least one registered manifest surface.', {
|
|
1001
|
+
code: ErrorCode.VALIDATION_FAILED,
|
|
1002
|
+
category: 'validation',
|
|
1003
|
+
guidance: 'Add surfaces such as `.cursor` or `.claude` to .productbrain/manifest.yaml.',
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
for (const surface of surfaceValidation.unregisteredSurfaces) {
|
|
1007
|
+
logErr(`Warning: manifest surface "${surface}" is not registered; skipping it.`);
|
|
1008
|
+
}
|
|
1009
|
+
const manifestTargets = new Set(surfaceValidation.registeredSurfaces);
|
|
1010
|
+
const cliTargets = new Set();
|
|
1011
|
+
const ignoredCliSurfaces = [];
|
|
1012
|
+
for (const surface of options.surfaces ?? []) {
|
|
1013
|
+
const normalized = normalizeSurfaceName(surface);
|
|
1014
|
+
if (normalized && manifestTargets.has(normalized)) {
|
|
1015
|
+
cliTargets.add(normalized);
|
|
1016
|
+
}
|
|
1017
|
+
else {
|
|
1018
|
+
ignoredCliSurfaces.push(surface);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
if (ignoredCliSurfaces.length > 0) {
|
|
1022
|
+
logErr(`Warning: --surfaces ignored outside manifest.surfaces: ${ignoredCliSurfaces.join(', ')}`);
|
|
1023
|
+
}
|
|
1024
|
+
const allowedTargets = options.surfaces && options.surfaces.length > 0
|
|
1025
|
+
? cliTargets
|
|
1026
|
+
: manifestTargets;
|
|
1027
|
+
const perimeterManifest = {
|
|
1028
|
+
surfaces: [...manifestTargets].flatMap(surfacePerimeterRoots),
|
|
1029
|
+
};
|
|
1030
|
+
const authorityCanWrite = manifestStatus.mode === 'project' || manifestStatus.mode === 'govern';
|
|
1031
|
+
const authorityPreviewOnly = applyMode && !authorityCanWrite;
|
|
1032
|
+
const writeMode = applyMode && authorityCanWrite;
|
|
675
1033
|
let dbSkills = [];
|
|
676
1034
|
let dbRules = [];
|
|
677
1035
|
let usedDbSource = false;
|
|
678
1036
|
let dbAssetRows = [];
|
|
679
1037
|
// WP-379 S4: dormant assets (gate-failed) — their on-disk files get the dormant marker.
|
|
680
1038
|
let dormantDbAssetRows = [];
|
|
681
|
-
// WP-
|
|
682
|
-
//
|
|
683
|
-
|
|
1039
|
+
// WP-428 S2 (Finding #5): tracks entryIds whose body fetch from storage failed.
|
|
1040
|
+
// Hoisted here (same scope as dbAssetRows) so both the skills/rules projection loop
|
|
1041
|
+
// AND the authoring-file projection loop can skip failed assets.
|
|
1042
|
+
const bodyFetchFailedEntryIds = new Set();
|
|
684
1043
|
const dbProjectionHashes = new Map();
|
|
1044
|
+
const syncDriftTensToFire = [];
|
|
1045
|
+
const deferredAuthoringBaselineEntryIds = new Set();
|
|
1046
|
+
if (writeMode) {
|
|
1047
|
+
const authoringItems = scanSetupAuthoringFiles(pbDir);
|
|
1048
|
+
if (authoringItems.length > 0) {
|
|
1049
|
+
// WP-428 S2 (Critical #1): ingestSetupAssetsBatch is an internalMutation — it cannot
|
|
1050
|
+
// call ctx.storage.store(). We now call ingestSetupAssetWithBody (action) per-asset so
|
|
1051
|
+
// each asset gets bodyStorageId written. This loses intra-batch collision detection;
|
|
1052
|
+
// canonical-id collision check is now client-side before dispatch.
|
|
1053
|
+
//
|
|
1054
|
+
// Client-side collision detection (replaces server-side DEC-954 check in the batch mutation):
|
|
1055
|
+
const idToPaths = new Map();
|
|
1056
|
+
for (const item of authoringItems) {
|
|
1057
|
+
const canonicalId = item.frontmatterId?.trim() || item.derivedEntryId;
|
|
1058
|
+
const paths = idToPaths.get(canonicalId) ?? [];
|
|
1059
|
+
paths.push(item.filePath);
|
|
1060
|
+
idToPaths.set(canonicalId, paths);
|
|
1061
|
+
}
|
|
1062
|
+
const conflictingPaths = new Set();
|
|
1063
|
+
for (const [, paths] of idToPaths) {
|
|
1064
|
+
if (paths.length > 1) {
|
|
1065
|
+
for (const p of paths)
|
|
1066
|
+
conflictingPaths.add(p);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
if (conflictingPaths.size > 0) {
|
|
1070
|
+
logErr(`Setup authoring import partial: ${conflictingPaths.size} file(s) refused for duplicate setup id. ` +
|
|
1071
|
+
[...conflictingPaths].join(', '));
|
|
1072
|
+
}
|
|
1073
|
+
const itemsToIngest = authoringItems.filter((item) => !conflictingPaths.has(item.filePath));
|
|
1074
|
+
if (itemsToIngest.length > 0) {
|
|
1075
|
+
try {
|
|
1076
|
+
// Parallel uploads — concurrency limit 10 to avoid overwhelming the gateway.
|
|
1077
|
+
const CONCURRENCY = 10;
|
|
1078
|
+
const ingestResults = [];
|
|
1079
|
+
for (let i = 0; i < itemsToIngest.length; i += CONCURRENCY) {
|
|
1080
|
+
const batch = itemsToIngest.slice(i, i + CONCURRENCY);
|
|
1081
|
+
const settled = await Promise.allSettled(batch.map(async (item) => {
|
|
1082
|
+
const result = await kernelCall('setup.ingestSetupAssetWithBody', {
|
|
1083
|
+
entryId: item.derivedEntryId,
|
|
1084
|
+
frontmatterId: item.frontmatterId,
|
|
1085
|
+
name: item.name,
|
|
1086
|
+
description: item.description,
|
|
1087
|
+
body: item.body,
|
|
1088
|
+
assetKind: item.assetKind,
|
|
1089
|
+
triggers: item.triggers,
|
|
1090
|
+
semanticRefs: item.semanticRefs,
|
|
1091
|
+
authoringPath: relative(pbDir, item.filePath).split(sep).join('/'),
|
|
1092
|
+
});
|
|
1093
|
+
return { filePath: item.filePath, ...result };
|
|
1094
|
+
}));
|
|
1095
|
+
for (const r of settled) {
|
|
1096
|
+
if (r.status === 'fulfilled') {
|
|
1097
|
+
ingestResults.push(r.value);
|
|
1098
|
+
if (r.value.conflict === 'repo-wins') {
|
|
1099
|
+
syncDriftTensToFire.push(`Repo authoring file won setup sync conflict for ${r.value.entryId} at ${r.value.filePath}.`);
|
|
1100
|
+
logErr(`Warning: repo authoring file won sync conflict for ${r.value.entryId}; DB edit was overwritten.`);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
else {
|
|
1104
|
+
trackEvent('setup.authoring_import.item_failed', { error: r.reason instanceof Error ? r.reason.message : String(r.reason) });
|
|
1105
|
+
logErr(`Warning: setup authoring import failed for one asset — ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
if (ingestResults.length > 0) {
|
|
1110
|
+
log(`Setup authoring import: ${ingestResults.length} file(s) checked (with bodyStorageId).`);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
catch (err) {
|
|
1114
|
+
trackEvent('setup.authoring_import.failed', { error: err instanceof Error ? err.message : String(err) });
|
|
1115
|
+
logErr(`Warning: setup authoring import failed — ${err instanceof Error ? err.message : String(err)}`);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
685
1120
|
try {
|
|
686
1121
|
// WP-379 S4: listAssetsForUser now returns { activeAssets, dormantAssets }.
|
|
687
1122
|
// Wire format changed from DbAsset[] to { activeAssets: DbAsset[], dormantAssets: DbAsset[] }.
|
|
@@ -693,28 +1128,73 @@ export async function runHandshake(options = {}) {
|
|
|
693
1128
|
// Pre-S4 server — treat entire response as active assets with no dormant list.
|
|
694
1129
|
dbAssets = rawResponse;
|
|
695
1130
|
dormantDbAssetRows = [];
|
|
696
|
-
hasAnyReceipt = undefined; // unknown on legacy servers
|
|
697
1131
|
}
|
|
698
1132
|
else {
|
|
699
1133
|
dbAssets = rawResponse.activeAssets ?? [];
|
|
700
1134
|
dormantDbAssetRows = rawResponse.dormantAssets ?? [];
|
|
701
|
-
// WP-379 S5b: extract hasAnyReceipt when provided by the server.
|
|
702
|
-
// undefined means pre-S5b server — we treat as unknown (no receipts assumed).
|
|
703
|
-
hasAnyReceipt = rawResponse.hasAnyReceipt;
|
|
704
1135
|
}
|
|
705
1136
|
}
|
|
706
1137
|
if (dbAssets.length > 0) {
|
|
707
1138
|
dbAssetRows = dbAssets;
|
|
1139
|
+
// WP-428 S2: body is no longer inline — fetch from storage per-asset when bodyStorageId is set.
|
|
1140
|
+
// Projectable assets: non-disabled skill/rule/hook entries. Fetch bodies in parallel (bounded).
|
|
1141
|
+
const projectableAssets = dbAssets.filter((a) => !a.disabledByOwner && (a.assetKind === 'skill' || a.assetKind === 'rule' || a.assetKind === 'hook'));
|
|
1142
|
+
const assetsNeedingBodyFetch = projectableAssets.filter((a) => a.bodyStorageId);
|
|
1143
|
+
const bodyFetchMap = new Map(); // entryId → body
|
|
1144
|
+
// WP-428 S2 (Finding #5/#12): track fetch-failed assets so we can skip their projection.
|
|
1145
|
+
// Failed entryIds are excluded from dbSkills/dbRules — no empty body written, no lastProjectedHash update.
|
|
1146
|
+
// bodyFetchFailedEntryIds is declared at outer scope (also used by authoring-file projection loop).
|
|
1147
|
+
if (assetsNeedingBodyFetch.length > 0) {
|
|
1148
|
+
log(`Fetching ${assetsNeedingBodyFetch.length} asset body(s) from storage...`);
|
|
1149
|
+
const bodyFetchResults = await Promise.allSettled(assetsNeedingBodyFetch.map(async (asset) => {
|
|
1150
|
+
const result = await kernelCall('setup.fetchAssetBody', { bodyStorageId: asset.bodyStorageId });
|
|
1151
|
+
return { entryId: asset.entryId, name: asset.name, body: result.body };
|
|
1152
|
+
}));
|
|
1153
|
+
for (let i = 0; i < bodyFetchResults.length; i++) {
|
|
1154
|
+
const result = bodyFetchResults[i];
|
|
1155
|
+
if (result.status === 'fulfilled') {
|
|
1156
|
+
bodyFetchMap.set(result.value.entryId, result.value.body);
|
|
1157
|
+
}
|
|
1158
|
+
else {
|
|
1159
|
+
// WP-428 S2 (Finding #12): include entryId and name in the warning (Finding #5: skip projection).
|
|
1160
|
+
const asset = assetsNeedingBodyFetch[i];
|
|
1161
|
+
logErr(`Warning: failed to fetch body for ${asset.entryId} (${asset.name}) — ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`);
|
|
1162
|
+
bodyFetchFailedEntryIds.add(asset.entryId);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
if (bodyFetchFailedEntryIds.size > 0) {
|
|
1166
|
+
// WP-439 S4: strict-by-default. Any body-fetch failure is a hard
|
|
1167
|
+
// failure unless --lenient is passed. The strict path surfaces an
|
|
1168
|
+
// actionable pointer at the audit/repair command operators can run
|
|
1169
|
+
// to diagnose and fix orphaned setup-asset rows.
|
|
1170
|
+
const failedList = [...bodyFetchFailedEntryIds].join(', ');
|
|
1171
|
+
if (options.lenient) {
|
|
1172
|
+
logErr(`Warning: ${bodyFetchFailedEntryIds.size} asset(s) skipped due to body fetch failure (--lenient): ${failedList}`);
|
|
1173
|
+
}
|
|
1174
|
+
else {
|
|
1175
|
+
throw new CLIError(`${bodyFetchFailedEntryIds.size} asset(s) failed body fetch: ${failedList}. ` +
|
|
1176
|
+
`Run \`pb setup-audit\` to diagnose, then \`pb setup-audit --repair\` to reseed. ` +
|
|
1177
|
+
`Pass --lenient to suppress this and continue with affected entries skipped (legacy behaviour).`, { code: ErrorCode.INTERNAL, category: 'internal' });
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
708
1181
|
// Map DB assets to CanonicalSkill/CanonicalRule shapes
|
|
709
1182
|
for (const asset of dbAssets) {
|
|
710
1183
|
if (asset.disabledByOwner)
|
|
711
1184
|
continue;
|
|
1185
|
+
// WP-428 S2 (Finding #5): skip projection for assets whose body fetch failed.
|
|
1186
|
+
// Do NOT write empty body to disk and do NOT update lastProjectedHash.
|
|
1187
|
+
if (bodyFetchFailedEntryIds.has(asset.entryId))
|
|
1188
|
+
continue;
|
|
1189
|
+
// WP-428 S2: resolve body — prefer storage-fetched body, fall back to inline body (pre-S2 servers).
|
|
1190
|
+
// Fallback: pre-S2 servers (no bodyStorageId) return inline body. Once all servers are S2+, this can be deleted.
|
|
1191
|
+
const resolvedBody = bodyFetchMap.get(asset.entryId) ?? asset.body ?? '';
|
|
712
1192
|
if (asset.assetKind === 'skill') {
|
|
713
1193
|
dbSkills.push({
|
|
714
1194
|
name: asset.name,
|
|
715
1195
|
description: asset.description,
|
|
716
1196
|
triggers: asset.triggers ?? [],
|
|
717
|
-
body:
|
|
1197
|
+
body: resolvedBody,
|
|
718
1198
|
sourcePath: `db:${asset.entryId}`,
|
|
719
1199
|
});
|
|
720
1200
|
}
|
|
@@ -723,7 +1203,7 @@ export async function runHandshake(options = {}) {
|
|
|
723
1203
|
name: asset.name,
|
|
724
1204
|
description: asset.description,
|
|
725
1205
|
autoApply: false,
|
|
726
|
-
body:
|
|
1206
|
+
body: resolvedBody,
|
|
727
1207
|
sourcePath: `db:${asset.entryId}`,
|
|
728
1208
|
});
|
|
729
1209
|
}
|
|
@@ -747,7 +1227,7 @@ export async function runHandshake(options = {}) {
|
|
|
747
1227
|
// For each asset with semanticRefs[], resolve them via the Convex resolver and
|
|
748
1228
|
// replace {{ref:key}} placeholders in the body. Runs in apply mode only (not preview).
|
|
749
1229
|
// NG11: PostHog events fire from CLI side only (never inside Convex mutations).
|
|
750
|
-
if (usedDbSource &&
|
|
1230
|
+
if (usedDbSource && writeMode) {
|
|
751
1231
|
const projectableDbAssets = dbAssetRows.filter((a) => !a.disabledByOwner && (a.assetKind === 'skill' || a.assetKind === 'rule' || a.assetKind === 'hook'));
|
|
752
1232
|
const assetsWithRefs = projectableDbAssets.filter((a) => a.semanticRefs && a.semanticRefs.length > 0);
|
|
753
1233
|
if (assetsWithRefs.length > 0) {
|
|
@@ -895,9 +1375,6 @@ export async function runHandshake(options = {}) {
|
|
|
895
1375
|
}
|
|
896
1376
|
allSkills.push(...personalSkills);
|
|
897
1377
|
}
|
|
898
|
-
// 5c. Apply manifest-based adoption filter (WP-310 E1)
|
|
899
|
-
// readManifest returns null when manifest.yaml is absent → filterByAdoptionState is a no-op.
|
|
900
|
-
const manifest = readManifest(pbDir);
|
|
901
1378
|
// 5d. Load method registry (WP-310 E4) — only when manifest is present.
|
|
902
1379
|
let registrySource;
|
|
903
1380
|
let registryStale;
|
|
@@ -964,7 +1441,7 @@ export async function runHandshake(options = {}) {
|
|
|
964
1441
|
const agentsWorkspaceContext = workspaceProfile
|
|
965
1442
|
? {
|
|
966
1443
|
stage: workspaceProfile.stage,
|
|
967
|
-
focus: orientView?.strategicContext?.
|
|
1444
|
+
focus: orientView?.strategicContext?.currentWorkPackage ?? undefined,
|
|
968
1445
|
governanceMode: workspaceProfile.governanceMode,
|
|
969
1446
|
totalEntries: workspaceProfile.totalEntries,
|
|
970
1447
|
}
|
|
@@ -1017,15 +1494,124 @@ export async function runHandshake(options = {}) {
|
|
|
1017
1494
|
const filesWritten = [];
|
|
1018
1495
|
const filesSkipped = [];
|
|
1019
1496
|
const previewPlan = [];
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1497
|
+
if (writeMode && usedDbSource) {
|
|
1498
|
+
const authoringSyncState = loadAuthoringSyncState(pbDir);
|
|
1499
|
+
let authoringSyncStateChanged = false;
|
|
1500
|
+
const personalAssets = dbAssetRows.filter((asset) => asset.scope === 'personal' &&
|
|
1501
|
+
!asset.disabledByOwner &&
|
|
1502
|
+
(asset.assetKind === 'skill' || asset.assetKind === 'rule' || asset.assetKind === 'hook'));
|
|
1503
|
+
for (const asset of personalAssets) {
|
|
1504
|
+
// WP-428 S2 (Finding #5): skip projection for assets whose body fetch failed.
|
|
1505
|
+
// Do NOT write the authoring file with empty body, do NOT update lastProjectedHash.
|
|
1506
|
+
if (bodyFetchFailedEntryIds.has(asset.entryId))
|
|
1507
|
+
continue;
|
|
1508
|
+
const authoringPath = setupAuthoringPath(cwd, asset);
|
|
1509
|
+
if (!authoringPath)
|
|
1510
|
+
continue;
|
|
1511
|
+
// Review: use relative() for correct cross-platform path computation (string-replace
|
|
1512
|
+
// mis-fires when cwd uses a non-'/' separator), matching the dormant passes below.
|
|
1513
|
+
const relativeAuthoringPath = relative(cwd, authoringPath);
|
|
1514
|
+
const bodyHash = setupAuthoringAssetHash(asset);
|
|
1515
|
+
const authoringExists = existsSync(authoringPath);
|
|
1516
|
+
const tracked = authoringSyncState.assets[asset.entryId];
|
|
1517
|
+
const trackedHere = tracked?.path === relativeAuthoringPath;
|
|
1518
|
+
const trackedMatchesServer = Boolean(trackedHere && asset.lastProjectedHash && tracked?.hash === asset.lastProjectedHash);
|
|
1519
|
+
if (!authoringExists && asset.lastProjectedHash && trackedHere && trackedMatchesServer) {
|
|
1520
|
+
try {
|
|
1521
|
+
const dormantResult = await kernelCall('setup.markPersonalSetupAssetDormantFromSync', { entryId: asset.entryId, expectedLastProjectedHash: tracked.hash });
|
|
1522
|
+
if (dormantResult.action === 'conflict') {
|
|
1523
|
+
syncDriftTensToFire.push(`Repo authoring deletion skipped for ${asset.entryId}; server baseline changed during sync, so writeback was deferred until the next DB read.`);
|
|
1524
|
+
logErr(`Warning: authoring deletion for ${asset.entryId} was not applied because the DB baseline changed during sync.`);
|
|
1525
|
+
deferredAuthoringBaselineEntryIds.add(asset.entryId);
|
|
1526
|
+
continue;
|
|
1527
|
+
}
|
|
1528
|
+
else {
|
|
1529
|
+
syncDriftTensToFire.push(`Repo authoring file was deleted for ${asset.entryId}; caller-owned personal setup asset marked dormant.`);
|
|
1530
|
+
log(`Setup authoring deletion: ${asset.entryId} marked dormant.`);
|
|
1531
|
+
delete authoringSyncState.assets[asset.entryId];
|
|
1532
|
+
authoringSyncStateChanged = true;
|
|
1533
|
+
continue;
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
catch (err) {
|
|
1537
|
+
logErr(`Warning: could not mark ${asset.entryId} dormant after authoring deletion — ${err instanceof Error ? err.message : String(err)}`);
|
|
1538
|
+
continue;
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
else if (!authoringExists && asset.lastProjectedHash && trackedHere) {
|
|
1542
|
+
syncDriftTensToFire.push(`Repo authoring deletion skipped for ${asset.entryId}; local sync baseline is stale, so DB writeback wins.`);
|
|
1543
|
+
logErr(`Warning: authoring deletion for ${asset.entryId} was not applied because the DB baseline changed.`);
|
|
1544
|
+
}
|
|
1545
|
+
const nextAuthoringContent = renderSetupAuthoringFile(asset);
|
|
1546
|
+
if (authoringExists) {
|
|
1547
|
+
const parsed = parseSetupAuthoringFrontmatter(readFileSync(authoringPath, 'utf8'));
|
|
1548
|
+
const fileHash = setupAuthoringAssetHash({
|
|
1549
|
+
entryId: parsed.frontmatterId?.trim() || asset.entryId,
|
|
1550
|
+
name: parsed.name || asset.name,
|
|
1551
|
+
description: parsed.description,
|
|
1552
|
+
body: parsed.body,
|
|
1553
|
+
assetKind: asset.assetKind,
|
|
1554
|
+
triggers: parsed.triggers,
|
|
1555
|
+
semanticRefs: parsed.semanticRefs,
|
|
1556
|
+
});
|
|
1557
|
+
if (fileHash === bodyHash) {
|
|
1558
|
+
if (asset.lastProjectedHash !== bodyHash) {
|
|
1559
|
+
await kernelCall('setup.updateLastProjectedHash', {
|
|
1560
|
+
entryId: asset.entryId,
|
|
1561
|
+
hash: bodyHash,
|
|
1562
|
+
}).catch(() => null);
|
|
1563
|
+
}
|
|
1564
|
+
authoringSyncState.assets[asset.entryId] = { path: relativeAuthoringPath, hash: bodyHash };
|
|
1565
|
+
authoringSyncStateChanged = true;
|
|
1566
|
+
continue;
|
|
1567
|
+
}
|
|
1568
|
+
if (!asset.lastProjectedHash) {
|
|
1569
|
+
syncDriftTensToFire.push(`Repo authoring file differs from DB for ${asset.entryId} at ${authoringPath} with no shared baseline; DB writeback skipped.`);
|
|
1570
|
+
logErr(`Warning: setup authoring drift for ${asset.entryId}; DB writeback skipped because no shared baseline exists.`);
|
|
1571
|
+
continue;
|
|
1572
|
+
}
|
|
1573
|
+
if (fileHash !== asset.lastProjectedHash &&
|
|
1574
|
+
bodyHash !== asset.lastProjectedHash) {
|
|
1575
|
+
syncDriftTensToFire.push(`Repo authoring file won setup sync conflict for ${asset.entryId} at ${authoringPath}.`);
|
|
1576
|
+
logErr(`Warning: repo authoring file won sync conflict for ${asset.entryId}; DB writeback skipped.`);
|
|
1577
|
+
continue;
|
|
1578
|
+
}
|
|
1579
|
+
if (fileHash !== asset.lastProjectedHash) {
|
|
1580
|
+
syncDriftTensToFire.push(`Repo authoring file differs from DB baseline for ${asset.entryId} at ${authoringPath}; DB writeback skipped.`);
|
|
1581
|
+
logErr(`Warning: setup authoring drift for ${asset.entryId}; DB writeback skipped.`);
|
|
1582
|
+
continue;
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
try {
|
|
1586
|
+
assertSetupWritePath(authoringPath, perimeterManifest);
|
|
1587
|
+
mkdirSync(dirname(authoringPath), { recursive: true });
|
|
1588
|
+
writeFileSync(authoringPath, nextAuthoringContent);
|
|
1589
|
+
filesWritten.push(relativeAuthoringPath);
|
|
1590
|
+
await kernelCall('setup.updateLastProjectedHash', {
|
|
1591
|
+
entryId: asset.entryId,
|
|
1592
|
+
hash: bodyHash,
|
|
1593
|
+
}).catch(() => null);
|
|
1594
|
+
authoringSyncState.assets[asset.entryId] = { path: relativeAuthoringPath, hash: bodyHash };
|
|
1595
|
+
authoringSyncStateChanged = true;
|
|
1596
|
+
}
|
|
1597
|
+
catch (err) {
|
|
1598
|
+
logErr(`Warning: could not write setup authoring file for ${asset.entryId} — ${err instanceof Error ? err.message : String(err)}`);
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
if (authoringSyncStateChanged) {
|
|
1602
|
+
try {
|
|
1603
|
+
saveAuthoringSyncState(pbDir, authoringSyncState);
|
|
1604
|
+
}
|
|
1605
|
+
catch (err) {
|
|
1606
|
+
logErr(`Warning: could not persist setup authoring sync state — ${err instanceof Error ? err.message : String(err)}`);
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1024
1610
|
const writes = [
|
|
1025
1611
|
...(contextContent ? [{ path: join(cwd, '.productbrain', 'context.md'), relative: '.productbrain/context.md', content: contextContent, dirs: join(cwd, '.productbrain'), isAdapter: false }] : []),
|
|
1026
1612
|
{ path: join(cwd, '.productbrain', 'briefing.md'), relative: '.productbrain/briefing.md', content: briefingContent, isAdapter: false },
|
|
1027
|
-
{ path: join(cwd, 'AGENTS.md'), relative: 'AGENTS.md', content: agentsContent, isAdapter: true, target: 'codex' },
|
|
1028
|
-
{ path: join(cwd, 'CLAUDE.md'), relative: 'CLAUDE.md', content: claudeContent, isAdapter: true, target: 'claude' },
|
|
1613
|
+
{ path: join(cwd, 'AGENTS.md'), relative: 'AGENTS.md', content: agentsContent, isAdapter: true, target: 'codex', augmentTarget: true },
|
|
1614
|
+
{ path: join(cwd, 'CLAUDE.md'), relative: 'CLAUDE.md', content: claudeContent, isAdapter: true, target: 'claude', augmentTarget: true },
|
|
1029
1615
|
{ path: join(cwd, '.cursor', 'rules', 'chain.mdc'), relative: '.cursor/rules/chain.mdc', content: cursorContent, dirs: join(cwd, '.cursor', 'rules'), isAdapter: true, target: 'cursor' },
|
|
1030
1616
|
{ path: join(cwd, '.github', 'copilot-instructions.md'), relative: '.github/copilot-instructions.md', content: copilotContent, dirs: join(cwd, '.github'), isAdapter: true, target: 'copilot' },
|
|
1031
1617
|
...(boundaryManifestContent ? [{
|
|
@@ -1129,40 +1715,159 @@ export async function runHandshake(options = {}) {
|
|
|
1129
1715
|
// Runs only when we have a DB asset list (usedDbSource) — without a DB source,
|
|
1130
1716
|
// we can't determine which files are canonical vs. orphan.
|
|
1131
1717
|
const collisionTensToFire = [];
|
|
1132
|
-
if (
|
|
1718
|
+
if (writeMode && usedDbSource) {
|
|
1133
1719
|
const activeAssetNames = dbAssetRows
|
|
1134
1720
|
.filter((a) => !a.disabledByOwner)
|
|
1135
1721
|
.map((a) => a.name);
|
|
1136
1722
|
const { collisionTens } = resolveProjectionCollision(cwd, activeAssetNames, log, logErr);
|
|
1137
1723
|
collisionTensToFire.push(...collisionTens);
|
|
1138
1724
|
}
|
|
1139
|
-
|
|
1725
|
+
// ── WP-436 S3: Vocab projector — fetch workspace vocab context once per handshake ──
|
|
1726
|
+
// Source-side (.productbrain/skills/*.md + rules/*.md) stays tokenized.
|
|
1727
|
+
// The projector resolves {{vocab:...}} tokens before writing adapter output to disk
|
|
1728
|
+
// (.cursor/rules/, .claude/rules/, CLAUDE.md, AGENTS.md, .github/copilot-instructions.md).
|
|
1729
|
+
// Fail-open: if vocab fetch fails, skip resolution and write raw token (no breakage).
|
|
1730
|
+
const handshakeVocabCtx = await getOrFetchVocabCtx(config.apiKey, async () => {
|
|
1731
|
+
try {
|
|
1732
|
+
const vocab = await kernelCall('chain.getVocabulary', {});
|
|
1733
|
+
if (vocab?.collectionLabels || vocab?.collectionDefaults) {
|
|
1734
|
+
return {
|
|
1735
|
+
...(vocab.collectionLabels ? { collectionLabels: vocab.collectionLabels } : {}),
|
|
1736
|
+
...(vocab.collectionDefaults ? { collectionDefaults: vocab.collectionDefaults } : {}),
|
|
1737
|
+
};
|
|
1738
|
+
}
|
|
1739
|
+
return null;
|
|
1740
|
+
}
|
|
1741
|
+
catch {
|
|
1742
|
+
return null; // fail-open
|
|
1743
|
+
}
|
|
1744
|
+
});
|
|
1745
|
+
const userOwnedSkipped = [];
|
|
1140
1746
|
const projectedHashUpdates = new Map();
|
|
1747
|
+
const cleanBucketPaths = [];
|
|
1748
|
+
const tamperedBucket = [];
|
|
1141
1749
|
const recordProjectedHash = (entryId) => {
|
|
1142
1750
|
if (!applyMode || !entryId)
|
|
1143
1751
|
return;
|
|
1752
|
+
if (deferredAuthoringBaselineEntryIds.has(entryId))
|
|
1753
|
+
return;
|
|
1144
1754
|
const projection = dbProjectionHashes.get(entryId);
|
|
1145
1755
|
if (projection)
|
|
1146
1756
|
projectedHashUpdates.set(entryId, projection.hash);
|
|
1147
1757
|
};
|
|
1758
|
+
// TEN-2155: region augmentation context + symlink dedupe.
|
|
1759
|
+
// codexActive gates the AGENTS.md skills-index pointer (region-projections.ts).
|
|
1760
|
+
const regionCtx = { codexActive: allowedTargets.has('codex') };
|
|
1761
|
+
const malformedRegionPaths = [];
|
|
1762
|
+
// If two augment targets resolve to the same inode (e.g. CLAUDE.md symlinked to AGENTS.md),
|
|
1763
|
+
// augment only the first-seen (AGENTS.md precedes CLAUDE.md in `writes`) to avoid double-injection.
|
|
1764
|
+
const seenAugmentRealpaths = new Set();
|
|
1765
|
+
const augmentTargetSkip = new Set();
|
|
1766
|
+
for (const w of writes) {
|
|
1767
|
+
if (!w.augmentTarget || !w.target || !allowedTargets.has(w.target) || !existsSync(w.path))
|
|
1768
|
+
continue;
|
|
1769
|
+
let real;
|
|
1770
|
+
try {
|
|
1771
|
+
real = realpathSync(w.path);
|
|
1772
|
+
}
|
|
1773
|
+
catch {
|
|
1774
|
+
real = w.path;
|
|
1775
|
+
}
|
|
1776
|
+
if (seenAugmentRealpaths.has(real))
|
|
1777
|
+
augmentTargetSkip.add(w.path);
|
|
1778
|
+
else
|
|
1779
|
+
seenAugmentRealpaths.add(real);
|
|
1780
|
+
}
|
|
1148
1781
|
for (const w of writes) {
|
|
1149
1782
|
// Surface filtering: skip adapter writes for targets not in the allowed set
|
|
1150
|
-
if (
|
|
1783
|
+
if (w.target && !allowedTargets.has(w.target)) {
|
|
1151
1784
|
filesSkipped.push({ path: w.relative, reason: `filtered (surface: ${w.target})` });
|
|
1152
1785
|
if (preview)
|
|
1153
1786
|
previewPlan.push({ path: w.relative, status: 'filtered' });
|
|
1154
1787
|
continue;
|
|
1155
1788
|
}
|
|
1789
|
+
// ── TEN-2155: augment user-owned CLAUDE.md / AGENTS.md with a marked PB region ──
|
|
1790
|
+
// Runs ahead of the legacy MARKER-keyed gates. Only existing augmentable/region-present
|
|
1791
|
+
// files are spliced here; absent + legacy-pb-managed fall through to the legacy path.
|
|
1792
|
+
if (w.augmentTarget) {
|
|
1793
|
+
const projection = REGION_PROJECTIONS[w.target];
|
|
1794
|
+
if (projection && existsSync(w.path)) {
|
|
1795
|
+
if (augmentTargetSkip.has(w.path)) {
|
|
1796
|
+
filesSkipped.push({ path: w.relative, reason: 'symlinked to another augment target — augmented once' });
|
|
1797
|
+
continue;
|
|
1798
|
+
}
|
|
1799
|
+
const disk = readFileSync(w.path, 'utf8');
|
|
1800
|
+
const cls = classifyAdapterFile(disk);
|
|
1801
|
+
if (cls === 'augmentable' || cls === 'region-present') {
|
|
1802
|
+
const eol = detectEol(disk);
|
|
1803
|
+
const region = replaceVocabTokens(projection.build(regionCtx, eol), handshakeVocabCtx);
|
|
1804
|
+
// Defense-in-depth: the composed region (post vocab-token resolution) must itself be a
|
|
1805
|
+
// single well-formed region. v1 content has no vocab tokens so this is a no-op, but it
|
|
1806
|
+
// guards the documented future override/vocab seam from injecting a stray sentinel/MARKER.
|
|
1807
|
+
if (classifyAdapterFile(region) !== 'region-present') {
|
|
1808
|
+
filesSkipped.push({ path: w.relative, reason: 'internal: composed PB region malformed — skipped to protect your file' });
|
|
1809
|
+
if (preview)
|
|
1810
|
+
previewPlan.push({ path: w.relative, status: 'needs-attention' });
|
|
1811
|
+
continue;
|
|
1812
|
+
}
|
|
1813
|
+
const candidate = cls === 'augmentable' ? spliceAppend(disk, region, eol) : spliceReplace(disk, region);
|
|
1814
|
+
if (candidate === disk) {
|
|
1815
|
+
filesSkipped.push({ path: w.relative, reason: 'unchanged' });
|
|
1816
|
+
if (preview)
|
|
1817
|
+
previewPlan.push({ path: w.relative, status: 'unchanged' });
|
|
1818
|
+
continue;
|
|
1819
|
+
}
|
|
1820
|
+
if (preview || dryRun) {
|
|
1821
|
+
filesWritten.push(w.relative + (dryRun ? ' (dry run)' : ''));
|
|
1822
|
+
if (preview)
|
|
1823
|
+
previewPlan.push({ path: w.relative, status: 'would-augment' });
|
|
1824
|
+
continue;
|
|
1825
|
+
}
|
|
1826
|
+
if (authorityPreviewOnly) {
|
|
1827
|
+
// apply + materialize observe/off: honor authority — do NOT write; mirror the legacy
|
|
1828
|
+
// preview-only skip so the report does not falsely list it as written.
|
|
1829
|
+
filesSkipped.push({ path: w.relative, reason: `preview-only (materialize: ${manifestStatus.mode})` });
|
|
1830
|
+
continue;
|
|
1831
|
+
}
|
|
1832
|
+
assertSetupWritePath(w.path, perimeterManifest);
|
|
1833
|
+
writeFileSync(w.path, candidate);
|
|
1834
|
+
filesWritten.push(w.relative);
|
|
1835
|
+
continue;
|
|
1836
|
+
}
|
|
1837
|
+
if (cls === 'malformed') {
|
|
1838
|
+
filesSkipped.push({ path: w.relative, reason: 'malformed PB region — left untouched; fix the pb:region sentinels' });
|
|
1839
|
+
if (preview)
|
|
1840
|
+
previewPlan.push({ path: w.relative, status: 'needs-attention' });
|
|
1841
|
+
malformedRegionPaths.push(w.relative);
|
|
1842
|
+
continue;
|
|
1843
|
+
}
|
|
1844
|
+
// cls 'pb-managed' → fall through to the legacy whole-file re-projection path below.
|
|
1845
|
+
// cls 'opt-out' (file carries the pb:no-augment sentinel — e.g. this repo's committed
|
|
1846
|
+
// constitution) → fall through too, landing on the legacy user-owned skip: left untouched,
|
|
1847
|
+
// never spliced. This is how a file declines augmentation without losing its user-owned status.
|
|
1848
|
+
}
|
|
1849
|
+
// absent file → fall through to legacy whole-file CREATE.
|
|
1850
|
+
}
|
|
1156
1851
|
if (w.isAdapter && !shouldWriteAdapter(w.path, force)) {
|
|
1157
|
-
|
|
1852
|
+
// User-owned: a file at an adapter path without our auto-gen MARKER is the
|
|
1853
|
+
// user's own file. Leave it untouched — never overwrite, never treat as drift
|
|
1854
|
+
// or log a TEN (TEN-2150). Relayed to the connect screen via userOwnedSkipped.
|
|
1855
|
+
filesSkipped.push({ path: w.relative, reason: 'user-owned — left untouched (pb won\'t overwrite your file)' });
|
|
1158
1856
|
if (preview) {
|
|
1159
|
-
previewPlan.push({ path: w.relative, status: '
|
|
1857
|
+
previewPlan.push({ path: w.relative, status: 'user-owned' });
|
|
1160
1858
|
}
|
|
1161
1859
|
else {
|
|
1162
|
-
|
|
1860
|
+
userOwnedSkipped.push(w.relative);
|
|
1163
1861
|
}
|
|
1164
1862
|
continue;
|
|
1165
1863
|
}
|
|
1864
|
+
if (authorityPreviewOnly) {
|
|
1865
|
+
filesSkipped.push({
|
|
1866
|
+
path: w.relative,
|
|
1867
|
+
reason: `preview-only (materialize: ${manifestStatus.mode})`,
|
|
1868
|
+
});
|
|
1869
|
+
continue;
|
|
1870
|
+
}
|
|
1166
1871
|
if (preview || dryRun) {
|
|
1167
1872
|
// In preview/dry-run mode: check content to distinguish new/update/unchanged
|
|
1168
1873
|
if (existsSync(w.path)) {
|
|
@@ -1187,11 +1892,44 @@ export async function runHandshake(options = {}) {
|
|
|
1187
1892
|
}
|
|
1188
1893
|
continue;
|
|
1189
1894
|
}
|
|
1895
|
+
// ── WP-421 S3: defer tampered adapter writes (doneWhen #17) ──────────────
|
|
1896
|
+
// For adapter projections that already exist on disk, classify into
|
|
1897
|
+
// pb-managed-clean / pb-managed-tampered. Tampered = MARKER present +
|
|
1898
|
+
// hash trailer mismatches body. Defer (do NOT overwrite); post-loop
|
|
1899
|
+
// resolution handles them. --force bypasses the tampered defer (legacy
|
|
1900
|
+
// semantic: explicit user opt-in to overwrite).
|
|
1901
|
+
if (w.isAdapter && !force) {
|
|
1902
|
+
const drift = classifyDriftBucket(w.path);
|
|
1903
|
+
if (drift && drift.bucket === 'pb-managed-tampered') {
|
|
1904
|
+
tamperedBucket.push({
|
|
1905
|
+
path: w.path,
|
|
1906
|
+
relative: w.relative,
|
|
1907
|
+
content: w.content,
|
|
1908
|
+
expectedHash: drift.expectedHash,
|
|
1909
|
+
actualHash: drift.actualHash,
|
|
1910
|
+
dirs: w.dirs,
|
|
1911
|
+
dbAssetEntryId: w.dbAssetEntryId,
|
|
1912
|
+
});
|
|
1913
|
+
// Do NOT write — defer until prompt/refusal resolves the file.
|
|
1914
|
+
continue;
|
|
1915
|
+
}
|
|
1916
|
+
if (drift && drift.bucket === 'pb-managed-clean') {
|
|
1917
|
+
cleanBucketPaths.push(w.relative);
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
assertSetupWritePath(w.path, perimeterManifest);
|
|
1190
1921
|
if (w.dirs)
|
|
1191
1922
|
mkdirSync(w.dirs, { recursive: true });
|
|
1923
|
+
// WP-436 S3: resolve {{vocab:...}} tokens before writing projected adapter files.
|
|
1924
|
+
// Source-side (.productbrain/skills/*.md, rules/*.md) stays tokenized.
|
|
1925
|
+
// Only adapter projections (cursor/rules, claude/rules, CLAUDE.md, AGENTS.md, etc.) get resolved.
|
|
1926
|
+
// Fail-open: if vocabCtx is undefined, replaceVocabTokens falls back to canonicalKey literals.
|
|
1927
|
+
const resolvedContent = w.isAdapter
|
|
1928
|
+
? replaceVocabTokens(w.content, handshakeVocabCtx)
|
|
1929
|
+
: w.content;
|
|
1192
1930
|
if (existsSync(w.path)) {
|
|
1193
1931
|
const current = readFileSync(w.path, 'utf8');
|
|
1194
|
-
const nextNormalized = normalizeHandshakeContentForComparison(
|
|
1932
|
+
const nextNormalized = normalizeHandshakeContentForComparison(resolvedContent);
|
|
1195
1933
|
const currentNormalized = normalizeHandshakeContentForComparison(current);
|
|
1196
1934
|
if (nextNormalized === currentNormalized) {
|
|
1197
1935
|
filesSkipped.push({ path: w.relative, reason: 'unchanged' });
|
|
@@ -1199,7 +1937,7 @@ export async function runHandshake(options = {}) {
|
|
|
1199
1937
|
continue;
|
|
1200
1938
|
}
|
|
1201
1939
|
}
|
|
1202
|
-
writeFileSync(w.path,
|
|
1940
|
+
writeFileSync(w.path, resolvedContent);
|
|
1203
1941
|
filesWritten.push(w.relative);
|
|
1204
1942
|
recordProjectedHash(w.dbAssetEntryId);
|
|
1205
1943
|
}
|
|
@@ -1216,69 +1954,366 @@ export async function runHandshake(options = {}) {
|
|
|
1216
1954
|
}
|
|
1217
1955
|
});
|
|
1218
1956
|
}
|
|
1219
|
-
//
|
|
1220
|
-
//
|
|
1221
|
-
//
|
|
1957
|
+
// Ordering note: this refusal runs AFTER the projected-hash flush so that files legitimately
|
|
1958
|
+
// written THIS run still record their hashes; malformed files were never written (no hash to record).
|
|
1959
|
+
// TEN-2155: in non-interactive apply, a malformed PB region is a refusal, not a silent skip.
|
|
1960
|
+
// Gated on applyMode (NOT writeMode): a malformed region is a user-file INTEGRITY fault, so the
|
|
1961
|
+
// refusal fires on any headless `--apply` regardless of materialize authority (STD-263 invariant vi —
|
|
1962
|
+
// "headless → non-zero refusal", unqualified). writeMode would suppress it under observe/off, where
|
|
1963
|
+
// the malformed file still gets enumerated but the corruption signal would be silently dropped.
|
|
1964
|
+
if (applyMode && malformedRegionPaths.length > 0 && (options.noPrompt || !process.stdout.isTTY)) {
|
|
1965
|
+
throw new CLIError(`Malformed PB region in: ${malformedRegionPaths.join(', ')}`, {
|
|
1966
|
+
code: ErrorCode.VALIDATION_FAILED,
|
|
1967
|
+
category: 'validation',
|
|
1968
|
+
guidance: 'Fix the `<!-- pb:region:start -->` / `<!-- pb:region:end -->` sentinels (one balanced pair), then re-run `pb handshake`.',
|
|
1969
|
+
});
|
|
1970
|
+
}
|
|
1971
|
+
// ── WP-421 S3: tampered-bucket resolution (doneWhen #17) ────────────────────
|
|
1972
|
+
// Apply mode only. Tampered files were DEFERRED in the write loop above;
|
|
1973
|
+
// here we either prompt the user (interactive TTY) or refuse (headless).
|
|
1222
1974
|
//
|
|
1223
|
-
//
|
|
1224
|
-
//
|
|
1225
|
-
//
|
|
1226
|
-
//
|
|
1227
|
-
//
|
|
1228
|
-
// separate, independent pass that runs BEFORE the drift TEN check — intentionally
|
|
1229
|
-
// after the main write loop to avoid interfering with active asset writes.
|
|
1975
|
+
// Headless = `--no-prompt` flag OR `process.stdout.isTTY === false`. When
|
|
1976
|
+
// headless: enumerate each tampered file to stderr, write a setup_receipt
|
|
1977
|
+
// row with kind='transition' (DEC-962) capturing `refusedTamperedFiles[]`,
|
|
1978
|
+
// then exit non-zero. NEVER auto-resolve. (#17 + exclusions: edits to
|
|
1979
|
+
// projection dirs are detected, NOT silently overwritten.)
|
|
1230
1980
|
//
|
|
1231
|
-
//
|
|
1981
|
+
// Interactive: for each tampered file, present adopt-or-revert. Adopt =
|
|
1982
|
+
// create a personal-scoped setup_asset draft from the tampered content
|
|
1983
|
+
// (see helper below). Revert = re-project canonical content over the
|
|
1984
|
+
// tampered file (write w.content to w.path).
|
|
1985
|
+
const adoptedTamperedPaths = [];
|
|
1986
|
+
const revertedTamperedPaths = [];
|
|
1987
|
+
if (writeMode && tamperedBucket.length > 0) {
|
|
1988
|
+
const headless = options.noPrompt === true || !process.stdout.isTTY;
|
|
1989
|
+
if (headless) {
|
|
1990
|
+
// ── Headless refusal path (doneWhen #17) ────────────────────────────────
|
|
1991
|
+
logErr('');
|
|
1992
|
+
logErr(`pb handshake: ${tamperedBucket.length} PB-managed projection file(s) were edited downstream of the auto-gen marker.`);
|
|
1993
|
+
logErr('Headless mode (--no-prompt or no TTY) cannot resolve adopt-or-revert — refusing.');
|
|
1994
|
+
logErr('');
|
|
1995
|
+
const refusedTamperedFiles = tamperedBucket.map((t) => ({
|
|
1996
|
+
path: t.relative,
|
|
1997
|
+
expectedHash: t.expectedHash,
|
|
1998
|
+
actualHash: t.actualHash,
|
|
1999
|
+
}));
|
|
2000
|
+
for (const refused of refusedTamperedFiles) {
|
|
2001
|
+
// Per #17: stderr enumerates each tampered file as
|
|
2002
|
+
// {path, expectedHash, actualHash, bucket}.
|
|
2003
|
+
logErr(` ${JSON.stringify({ ...refused, bucket: 'pb-managed-tampered' })}`);
|
|
2004
|
+
}
|
|
2005
|
+
logErr('');
|
|
2006
|
+
logErr('Re-run interactively to resolve, or use --force to overwrite (data loss).');
|
|
2007
|
+
// Write the kind='transition' setup_receipt row. Fail-open on the
|
|
2008
|
+
// network/auth side: if the row cannot be written, we still exit non-zero
|
|
2009
|
+
// (the audit trail is best-effort; refusal is mandatory).
|
|
2010
|
+
try {
|
|
2011
|
+
const manifestStatus = readManifestStatus(pbDir);
|
|
2012
|
+
await kernelCall('setup.recordTamperRefusal', {
|
|
2013
|
+
mode: manifestStatus.mode,
|
|
2014
|
+
refusedTamperedFiles,
|
|
2015
|
+
});
|
|
2016
|
+
trackEvent('setup.transition.refused', {
|
|
2017
|
+
fileCount: refusedTamperedFiles.length,
|
|
2018
|
+
mode: manifestStatus.mode,
|
|
2019
|
+
});
|
|
2020
|
+
}
|
|
2021
|
+
catch (err) {
|
|
2022
|
+
trackEvent('setup.transition.refused.write_failed', {
|
|
2023
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2024
|
+
});
|
|
2025
|
+
logErr(`Warning: could not record transition receipt — ${err instanceof Error ? err.message : String(err)}`);
|
|
2026
|
+
}
|
|
2027
|
+
// Surface the report counts before exit (visibility for CI logs).
|
|
2028
|
+
if (!quiet) {
|
|
2029
|
+
process.stdout.write('\n');
|
|
2030
|
+
process.stdout.write(formatHandshakeReport({
|
|
2031
|
+
filesWritten,
|
|
2032
|
+
filesSkipped,
|
|
2033
|
+
matchedEntries,
|
|
2034
|
+
searchQueries: uniqueQueries,
|
|
2035
|
+
repo,
|
|
2036
|
+
codexWarnings: codexWarnings.length > 0 ? codexWarnings : undefined,
|
|
2037
|
+
chainRulesStats: chainRulesStats ?? undefined,
|
|
2038
|
+
chainGaps: chainGaps.length > 0 ? chainGaps : undefined,
|
|
2039
|
+
adoptedCount: adoptedRulesCount,
|
|
2040
|
+
rejectedCount: rejectedRulesCount,
|
|
2041
|
+
personalRuleCount: personalRules.length > 0 ? personalRules.length : undefined,
|
|
2042
|
+
personalSkillCount: personalSkills.length > 0 ? personalSkills.length : undefined,
|
|
2043
|
+
registrySource,
|
|
2044
|
+
registryStale,
|
|
2045
|
+
userOwnedSkipped: userOwnedSkipped.length > 0 ? userOwnedSkipped : undefined,
|
|
2046
|
+
managedCleanCount: cleanBucketPaths.length || undefined,
|
|
2047
|
+
tamperedFiles: refusedTamperedFiles,
|
|
2048
|
+
}) + '\n');
|
|
2049
|
+
}
|
|
2050
|
+
// Exit non-zero per #17.
|
|
2051
|
+
process.exit(1);
|
|
2052
|
+
}
|
|
2053
|
+
// ── Interactive path: prompt adopt-or-revert per file ──────────────────────
|
|
2054
|
+
log('');
|
|
2055
|
+
log(`pb handshake: ${tamperedBucket.length} PB-managed projection file(s) were edited downstream of the auto-gen marker.`);
|
|
2056
|
+
log('You can ADOPT (capture your edits as a personal-scoped draft) or REVERT (overwrite with canonical content).');
|
|
2057
|
+
// Batch yes-to-all / no-to-all when the user has many tampered files.
|
|
2058
|
+
// Threshold: 5 (arbitrary; mirrors the typical handshake projection set).
|
|
2059
|
+
let batchChoice = null;
|
|
2060
|
+
if (tamperedBucket.length >= 5) {
|
|
2061
|
+
const useBatch = await promptConfirm({
|
|
2062
|
+
message: `Apply the same choice to all ${tamperedBucket.length} tampered files?`,
|
|
2063
|
+
initialValue: false,
|
|
2064
|
+
});
|
|
2065
|
+
if (useBatch) {
|
|
2066
|
+
const choice = await promptSelect({
|
|
2067
|
+
message: 'Apply to all:',
|
|
2068
|
+
options: [
|
|
2069
|
+
{ value: 'adopt', label: 'Adopt all — capture each as a personal-scoped draft' },
|
|
2070
|
+
{ value: 'revert', label: 'Revert all — overwrite with canonical content' },
|
|
2071
|
+
],
|
|
2072
|
+
});
|
|
2073
|
+
batchChoice = choice === 'adopt' ? 'adopt-all' : 'revert-all';
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
for (const tamper of tamperedBucket) {
|
|
2077
|
+
// Reverse-map the projection path back to the canonical authoring path.
|
|
2078
|
+
const reverse = canonicalPathForAnySurface(tamper.relative);
|
|
2079
|
+
const canonicalHint = reverse
|
|
2080
|
+
? `Canonical authoring path: ${reverse.canonicalPath}`
|
|
2081
|
+
: (() => {
|
|
2082
|
+
// Telemetry + fallback message per surfaces/telemetry.ts.
|
|
2083
|
+
reportReverseMapMissing({ surface: 'unknown', projectionPath: tamper.relative }, logErr);
|
|
2084
|
+
return `Canonical authoring path: ${getReverseMapFallbackMessage()}`;
|
|
2085
|
+
})();
|
|
2086
|
+
log('');
|
|
2087
|
+
log(`Tampered: ${tamper.relative}`);
|
|
2088
|
+
log(` expected: ${tamper.expectedHash}`);
|
|
2089
|
+
log(` actual: ${tamper.actualHash}`);
|
|
2090
|
+
log(` ${canonicalHint}`);
|
|
2091
|
+
let action;
|
|
2092
|
+
if (batchChoice === 'adopt-all')
|
|
2093
|
+
action = 'adopt';
|
|
2094
|
+
else if (batchChoice === 'revert-all')
|
|
2095
|
+
action = 'revert';
|
|
2096
|
+
else {
|
|
2097
|
+
action = await promptSelect({
|
|
2098
|
+
message: 'Adopt or revert?',
|
|
2099
|
+
options: [
|
|
2100
|
+
{ value: 'adopt', label: 'Adopt — capture this as a personal-scoped setup_asset draft' },
|
|
2101
|
+
{ value: 'revert', label: 'Revert — overwrite with canonical content' },
|
|
2102
|
+
],
|
|
2103
|
+
});
|
|
2104
|
+
}
|
|
2105
|
+
if (action === 'revert') {
|
|
2106
|
+
// Re-project canonical content over the tampered file.
|
|
2107
|
+
// WP-436 S3: resolve vocab tokens before writing (all tampered files are adapters).
|
|
2108
|
+
if (tamper.dirs)
|
|
2109
|
+
mkdirSync(tamper.dirs, { recursive: true });
|
|
2110
|
+
assertSetupWritePath(tamper.path, perimeterManifest);
|
|
2111
|
+
writeFileSync(tamper.path, replaceVocabTokens(tamper.content, handshakeVocabCtx));
|
|
2112
|
+
revertedTamperedPaths.push(tamper.relative);
|
|
2113
|
+
recordProjectedHash(tamper.dbAssetEntryId);
|
|
2114
|
+
trackEvent('setup.tampered.reverted', { path: tamper.relative });
|
|
2115
|
+
}
|
|
2116
|
+
else {
|
|
2117
|
+
// Adopt: capture the tampered content as a personal-scoped draft.
|
|
2118
|
+
// Per DEC-953 sync rules: personal scope = push (no fork required).
|
|
2119
|
+
// The mutation is best-effort — if the adopt write fails, the file is
|
|
2120
|
+
// kept on disk untouched and a warning is logged. Adopt does NOT
|
|
2121
|
+
// revert; it preserves the user's edits AND records them as a draft.
|
|
2122
|
+
const draftName = basename(tamper.relative).replace(/\.(md|mdc)$/, '') + ' (adopted)';
|
|
2123
|
+
try {
|
|
2124
|
+
const tamperedContent = readFileSync(tamper.path, 'utf8');
|
|
2125
|
+
const session = readSession();
|
|
2126
|
+
const caller = session ? kernelCallWithSession : kernelCall;
|
|
2127
|
+
await caller('setup.ingestSetupAsset', {
|
|
2128
|
+
entryId: `SETUP-ADOPTED-${Date.now()}-${draftName.replace(/\s+/g, '-')}`,
|
|
2129
|
+
name: draftName,
|
|
2130
|
+
// WP-421 S3
|
|
2131
|
+
description: `Adopted from tampered projection at ${tamper.relative}.`,
|
|
2132
|
+
body: tamperedContent,
|
|
2133
|
+
assetKind: tamper.relative.includes('/skills/') ? 'skill' : 'rule',
|
|
2134
|
+
triggers: [],
|
|
2135
|
+
semanticRefs: [],
|
|
2136
|
+
});
|
|
2137
|
+
adoptedTamperedPaths.push(tamper.relative);
|
|
2138
|
+
trackEvent('setup.tampered.adopted', { path: tamper.relative });
|
|
2139
|
+
}
|
|
2140
|
+
catch (err) {
|
|
2141
|
+
logErr(`Warning: could not adopt ${tamper.relative} as draft — ${err instanceof Error ? err.message : String(err)}`);
|
|
2142
|
+
trackEvent('setup.tampered.adopt_failed', {
|
|
2143
|
+
path: tamper.relative,
|
|
2144
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2145
|
+
});
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
// 8a. Dormant marker + .dormant rename (WP-379 S4 + WP-426 E4) — apply mode only.
|
|
1232
2151
|
const dormantMarkedPaths = [];
|
|
1233
|
-
if (
|
|
2152
|
+
if (writeMode && dormantDbAssetRows.length > 0) {
|
|
2153
|
+
const dormantState = loadAuthoringSyncState(pbDir);
|
|
2154
|
+
let dormantStateChanged = false;
|
|
1234
2155
|
for (const dormantAsset of dormantDbAssetRows) {
|
|
1235
|
-
const
|
|
1236
|
-
|
|
2156
|
+
for (const { path: filePath, surface } of deriveDormantFilePaths(dormantAsset, cwd)) {
|
|
2157
|
+
// Codex P2: deriveDormantFilePaths emits every known surface path. Honor the run's
|
|
2158
|
+
// target set (allowedTargets = the --surfaces selection, else all manifest surfaces)
|
|
2159
|
+
// so a `--surfaces cursor` run never renames .claude/.codex to .dormant, and surfaces
|
|
2160
|
+
// outside the manifest are skipped silently (no false "could not dormant-mark" warning).
|
|
2161
|
+
if (!allowedTargets.has(surface))
|
|
2162
|
+
continue;
|
|
2163
|
+
// FIX 4: use relative() instead of string-replace for correct cross-platform behaviour.
|
|
2164
|
+
const rel = relative(cwd, filePath);
|
|
2165
|
+
const alreadyReactivated = dormantState.dormantReactivated?.includes(rel) ?? false;
|
|
2166
|
+
const previouslyRenamed = dormantState.dormantRenamed?.includes(rel) ?? false;
|
|
1237
2167
|
try {
|
|
2168
|
+
assertSetupWritePath(filePath, perimeterManifest);
|
|
2169
|
+
// WP-426 E4: BUG 1 fix — hands-off set.
|
|
2170
|
+
//
|
|
2171
|
+
// Step 1: permanently hands-off — user already reactivated this path on a
|
|
2172
|
+
// prior run. Skip silently every time until they re-lower or raise in PB.
|
|
2173
|
+
// (Task 7 raise-cleanup must later also prune dormantReactivated for raised
|
|
2174
|
+
// assets — see the `dormantReactivated` comment on AuthoringSyncState.)
|
|
2175
|
+
if (alreadyReactivated) {
|
|
2176
|
+
continue; // leave file untouched; no TEN (already fired on first detection)
|
|
2177
|
+
}
|
|
2178
|
+
// Step 2: FIRST detection of manual reactivation — we previously renamed
|
|
2179
|
+
// this to .dormant but the user renamed it back to a live file. Push the
|
|
2180
|
+
// drift TEN exactly once, add to the permanent hands-off set, remove from
|
|
2181
|
+
// dormantRenamed, leave file untouched.
|
|
2182
|
+
if (previouslyRenamed && existsSync(filePath) && !existsSync(`${filePath}.dormant`)) {
|
|
2183
|
+
syncDriftTensToFire.push(`Dormant asset ${dormantAsset.entryId} was manually reactivated at ${rel}; left untouched. Re-lower or raise it in PB to resync.`);
|
|
2184
|
+
logErr(`Warning: ${rel} was manually un-dormanted; leaving it in place (drift).`);
|
|
2185
|
+
dormantState.dormantReactivated = [...(dormantState.dormantReactivated ?? []), rel];
|
|
2186
|
+
dormantState.dormantRenamed = (dormantState.dormantRenamed ?? []).filter((p) => p !== rel);
|
|
2187
|
+
dormantStateChanged = true;
|
|
2188
|
+
continue;
|
|
2189
|
+
}
|
|
2190
|
+
// Step 3: normal lowering — write dormant marker + rename to .dormant.
|
|
2191
|
+
// WP-426 E4 spec: every rename TARGET must also pass the perimeter guard.
|
|
2192
|
+
// Check the post-rename .dormant path BEFORE any FS mutation, so a guard
|
|
2193
|
+
// failure can't leave a half-dormant file (marker appended but not renamed).
|
|
2194
|
+
assertSetupWritePath(`${filePath}.dormant`, perimeterManifest);
|
|
1238
2195
|
const markerResult = writeDormantMarkerToFile(filePath);
|
|
1239
|
-
if (markerResult === 'written')
|
|
1240
|
-
dormantMarkedPaths.push(filePath);
|
|
2196
|
+
if (markerResult === 'written')
|
|
1241
2197
|
log(`Dormant marker written: ${filePath}`);
|
|
2198
|
+
const renameResult = renameSurfaceForDormancy(filePath);
|
|
2199
|
+
if (renameResult === 'renamed' || renameResult === 'replaced') {
|
|
2200
|
+
// dormantMarkedPaths holds post-rename .dormant paths (not the original surface paths).
|
|
2201
|
+
dormantMarkedPaths.push(`${filePath}.dormant`);
|
|
2202
|
+
if (!dormantState.dormantRenamed?.includes(rel)) {
|
|
2203
|
+
dormantState.dormantRenamed = [...(dormantState.dormantRenamed ?? []), rel];
|
|
2204
|
+
dormantStateChanged = true;
|
|
2205
|
+
}
|
|
2206
|
+
log(`Dormant rename: ${filePath} → ${filePath}.dormant`);
|
|
2207
|
+
}
|
|
2208
|
+
else if (renameResult === 'drift') {
|
|
2209
|
+
// Codex P1: an edited .dormant already exists alongside the live file. Preserve
|
|
2210
|
+
// both and flag, rather than overwriting the user's edited dormant copy.
|
|
2211
|
+
syncDriftTensToFire.push(`Edited dormant copy ${rel}.dormant diverges from the live surface for ${dormantAsset.entryId}; left both in place. Resolve manually.`);
|
|
2212
|
+
logErr(`Warning: ${rel}.dormant diverges from the live file; not replacing (possible manual edit).`);
|
|
1242
2213
|
}
|
|
1243
|
-
// 'already-dormant' and 'skipped' are silent no-ops — idempotent.
|
|
1244
2214
|
}
|
|
1245
2215
|
catch (err) {
|
|
1246
|
-
|
|
1247
|
-
logErr(`Warning: could not write dormant marker to ${filePath} — ${err instanceof Error ? err.message : String(err)}`);
|
|
2216
|
+
logErr(`Warning: could not dormant-mark ${filePath} — ${err instanceof Error ? err.message : String(err)}`);
|
|
1248
2217
|
}
|
|
1249
2218
|
}
|
|
1250
2219
|
}
|
|
2220
|
+
if (dormantMarkedPaths.length > 0) {
|
|
2221
|
+
log('Run `pb setup observe --purge` to remove these instead of dormant-renaming.');
|
|
2222
|
+
}
|
|
2223
|
+
if (dormantStateChanged) {
|
|
2224
|
+
try {
|
|
2225
|
+
saveAuthoringSyncState(pbDir, dormantState);
|
|
2226
|
+
}
|
|
2227
|
+
catch (err) {
|
|
2228
|
+
logErr(`Warning: could not persist dormancy state — ${err instanceof Error ? err.message : String(err)}`);
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
1251
2231
|
}
|
|
1252
|
-
//
|
|
1253
|
-
//
|
|
1254
|
-
//
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
2232
|
+
// 8b. Raise-cleanup (WP-426 E4): for assets active this run, drop any orphan
|
|
2233
|
+
// <surface>.dormant left by a prior lowering (active surface was re-projected
|
|
2234
|
+
// fresh above). User-edited dormant copies are preserved + flagged. Fail-open.
|
|
2235
|
+
if (writeMode) {
|
|
2236
|
+
const raiseState = loadAuthoringSyncState(pbDir);
|
|
2237
|
+
let raiseStateChanged = false;
|
|
2238
|
+
const dormantIds = new Set(dormantDbAssetRows.map((a) => a.entryId));
|
|
2239
|
+
const activeRows = dbAssetRows.filter((a) => !dormantIds.has(a.entryId) &&
|
|
2240
|
+
(a.assetKind === 'skill' || a.assetKind === 'rule' || a.assetKind === 'hook'));
|
|
2241
|
+
for (const asset of activeRows) {
|
|
2242
|
+
// Codex P2: if this asset's body fetch failed, the write loop did NOT reproject its
|
|
2243
|
+
// surfaces this run (same skip the authoring loop applies), so there's no fresh surface
|
|
2244
|
+
// to reconcile against — skip raise-cleanup to avoid acting on stale content.
|
|
2245
|
+
if (bodyFetchFailedEntryIds.has(asset.entryId))
|
|
2246
|
+
continue;
|
|
2247
|
+
for (const { path: filePath, surface } of deriveDormantFilePaths(asset, cwd)) {
|
|
2248
|
+
// Codex P2: honor the run's target set (parity with the dormant pass) — a
|
|
2249
|
+
// `--surfaces cursor` run must not touch .claude/.codex .dormant files, and surfaces
|
|
2250
|
+
// outside the manifest are skipped silently (no false "raise-cleanup failed" warning).
|
|
2251
|
+
if (!allowedTargets.has(surface))
|
|
2252
|
+
continue;
|
|
2253
|
+
const rel = relative(cwd, filePath);
|
|
2254
|
+
try {
|
|
2255
|
+
assertSetupWritePath(filePath, perimeterManifest); // WP-426 E4: perimeter before any FS mutation (parity with the dormant pass)
|
|
2256
|
+
// E4 spec parity (review): restoreSurfaceFromDormant deletes the .dormant
|
|
2257
|
+
// sibling, so guard that delete TARGET too — exactly as the lowering pass
|
|
2258
|
+
// guards both filePath and ${filePath}.dormant.
|
|
2259
|
+
assertSetupWritePath(`${filePath}.dormant`, perimeterManifest);
|
|
2260
|
+
const r = restoreSurfaceFromDormant(filePath);
|
|
2261
|
+
if (r === 'restored') {
|
|
2262
|
+
log(`Raised: removed superseded ${filePath}.dormant`);
|
|
2263
|
+
}
|
|
2264
|
+
else if (r === 'orphan-drift') {
|
|
2265
|
+
syncDriftTensToFire.push(`Dormant copy ${rel}.dormant was edited while dormant for ${asset.entryId}; preserved on raise. Resolve manually.`);
|
|
2266
|
+
logErr(`Warning: ${rel}.dormant differs from the freshly raised projection; left in place.`);
|
|
2267
|
+
}
|
|
2268
|
+
// Prune the registry only when no .dormant sibling remains for this surface:
|
|
2269
|
+
// • 'restored' removed it, or
|
|
2270
|
+
// • it was already gone (manual .dormant→live reactivation; 'skipped', no .dormant).
|
|
2271
|
+
// Carry-over obligation (Phase 3) + Codex P1: the manual-reactivation case must
|
|
2272
|
+
// leave the hands-off set so a future lowering can re-evaluate the surface.
|
|
2273
|
+
// Codex P2: but if a .dormant STILL exists (surface-filtered 'skipped' with no
|
|
2274
|
+
// fresh live file, or a preserved 'orphan-drift'), KEEP the registry evidence —
|
|
2275
|
+
// otherwise a later manual .dormant→live reactivation would not be recognized as
|
|
2276
|
+
// previouslyRenamed and would be silently re-dormanted on the next lowering.
|
|
2277
|
+
if (!existsSync(`${filePath}.dormant`)) {
|
|
2278
|
+
if (raiseState.dormantRenamed?.includes(rel)) {
|
|
2279
|
+
raiseState.dormantRenamed = raiseState.dormantRenamed.filter((p) => p !== rel);
|
|
2280
|
+
raiseStateChanged = true;
|
|
2281
|
+
}
|
|
2282
|
+
if (raiseState.dormantReactivated?.includes(rel)) {
|
|
2283
|
+
raiseState.dormantReactivated = raiseState.dormantReactivated.filter((p) => p !== rel);
|
|
2284
|
+
raiseStateChanged = true;
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
catch (err) {
|
|
2289
|
+
logErr(`Warning: raise-cleanup failed for ${filePath} — ${err instanceof Error ? err.message : String(err)}`);
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
1271
2292
|
}
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
2293
|
+
if (raiseStateChanged) {
|
|
2294
|
+
try {
|
|
2295
|
+
saveAuthoringSyncState(pbDir, raiseState);
|
|
2296
|
+
}
|
|
2297
|
+
catch { /* fail-open */ }
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
// 8. User-owned files left untouched are NOT drift (TEN-2150).
|
|
2301
|
+
// A marker-less file at an adapter path is the user's own file: handshake never
|
|
2302
|
+
// wrote it, so there is nothing to "sync" and no draft TEN is logged. These files
|
|
2303
|
+
// are surfaced benignly under "Skipped:" and relayed to the connect screen via
|
|
2304
|
+
// report.userOwnedSkipped. (The legitimate "tampered" and authoring-sync drift
|
|
2305
|
+
// signals below are unaffected.)
|
|
2306
|
+
if (syncDriftTensToFire.length > 0) {
|
|
2307
|
+
const session = readSession();
|
|
2308
|
+
if (session) {
|
|
2309
|
+
for (const driftDescription of syncDriftTensToFire) {
|
|
1276
2310
|
kernelCallWithSession('chain.createEntry', {
|
|
1277
2311
|
collectionSlug: 'tensions',
|
|
1278
|
-
name:
|
|
2312
|
+
name: 'TEN: setup authoring sync drift — repo wins',
|
|
1279
2313
|
status: 'draft',
|
|
1280
2314
|
data: {
|
|
1281
|
-
|
|
2315
|
+
kind: 'drift',
|
|
2316
|
+
description: driftDescription,
|
|
1282
2317
|
},
|
|
1283
2318
|
sessionId: session.sessionId,
|
|
1284
2319
|
createdBy: `agent:${session.sessionId}`,
|
|
@@ -1286,10 +2321,10 @@ export async function runHandshake(options = {}) {
|
|
|
1286
2321
|
}
|
|
1287
2322
|
}
|
|
1288
2323
|
}
|
|
1289
|
-
// 8. Case-collision TENs (WP-379 S5b)
|
|
2324
|
+
// 8. Case-collision TENs (WP-379 S5b).
|
|
1290
2325
|
// These are distinct from drift TENs: they record ambiguous filename collisions
|
|
1291
|
-
// where the "newest mtime wins" heuristic was applied. They fire
|
|
1292
|
-
//
|
|
2326
|
+
// where the "newest mtime wins" heuristic was applied. They always fire on a
|
|
2327
|
+
// detected collision (collision is a data quality issue, not a drift issue).
|
|
1293
2328
|
if (collisionTensToFire.length > 0) {
|
|
1294
2329
|
const session = readSession();
|
|
1295
2330
|
if (session) {
|
|
@@ -1297,6 +2332,8 @@ export async function runHandshake(options = {}) {
|
|
|
1297
2332
|
kernelCallWithSession('chain.createEntry', {
|
|
1298
2333
|
collectionSlug: 'tensions',
|
|
1299
2334
|
name: `TEN: handshake case-collision — ambiguous filename resolved by mtime`,
|
|
2335
|
+
// Collision audit TENs intentionally stay draft — they need explicit human review,
|
|
2336
|
+
// not auto-commit, even in Open mode (mirrors smart-capture.ts recordCommitFailure).
|
|
1300
2337
|
status: 'draft',
|
|
1301
2338
|
data: { description: tenDescription },
|
|
1302
2339
|
sessionId: session.sessionId,
|
|
@@ -1308,6 +2345,30 @@ export async function runHandshake(options = {}) {
|
|
|
1308
2345
|
// 8b. Setup receipt — record which assets were materialized (apply mode only)
|
|
1309
2346
|
// Fail-open: receipt write is advisory, never blocks the handshake.
|
|
1310
2347
|
if (applyMode) {
|
|
2348
|
+
const session = readSession();
|
|
2349
|
+
const caller = session ? kernelCallWithSession : kernelCall;
|
|
2350
|
+
try {
|
|
2351
|
+
const currentState = await caller('setup.getCurrentSetupState', {});
|
|
2352
|
+
const fromMode = currentState?.effectiveMode ?? 'observe';
|
|
2353
|
+
if (fromMode !== manifestStatus.mode) {
|
|
2354
|
+
await caller('setup.recordTransition', {
|
|
2355
|
+
fromMode,
|
|
2356
|
+
toMode: manifestStatus.mode,
|
|
2357
|
+
parseStatus: manifestStatus.parseStatus,
|
|
2358
|
+
surfaces: manifestStatus.surfaces,
|
|
2359
|
+
lock: manifestStatus.lock,
|
|
2360
|
+
});
|
|
2361
|
+
if (modeRank(manifestStatus.mode) < modeRank(fromMode)) {
|
|
2362
|
+
trackEvent('setup.transition.lowered', { fromMode, toMode: manifestStatus.mode });
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
catch (err) {
|
|
2367
|
+
trackEvent('setup.transition.write_failed', { error: err instanceof Error ? err.message : String(err) });
|
|
2368
|
+
logErr(`Warning: could not record setup transition — ${err instanceof Error ? err.message : String(err)}`);
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
if (writeMode) {
|
|
1311
2372
|
const session = readSession();
|
|
1312
2373
|
const caller = session ? kernelCallWithSession : kernelCall;
|
|
1313
2374
|
try {
|
|
@@ -1339,11 +2400,22 @@ export async function runHandshake(options = {}) {
|
|
|
1339
2400
|
registryStale,
|
|
1340
2401
|
preview: preview ? true : undefined,
|
|
1341
2402
|
previewPlan: preview && previewPlan.length > 0 ? previewPlan : undefined,
|
|
1342
|
-
|
|
2403
|
+
userOwnedSkipped: userOwnedSkipped.length > 0 ? userOwnedSkipped : undefined,
|
|
2404
|
+
// WP-421 S3: three-bucket drift report (doneWhen #17). PB-managed-clean is
|
|
2405
|
+
// the count of files whose marker + hash matched. Tampered files were
|
|
2406
|
+
// resolved (adopted/reverted) above and are reported separately.
|
|
2407
|
+
managedCleanCount: cleanBucketPaths.length > 0 ? cleanBucketPaths.length : undefined,
|
|
2408
|
+
adoptedTamperedPaths: adoptedTamperedPaths.length > 0 ? adoptedTamperedPaths : undefined,
|
|
2409
|
+
revertedTamperedPaths: revertedTamperedPaths.length > 0 ? revertedTamperedPaths : undefined,
|
|
1343
2410
|
};
|
|
1344
2411
|
if (!quiet) {
|
|
1345
2412
|
process.stdout.write('\n');
|
|
1346
2413
|
process.stdout.write(formatHandshakeReport(report) + '\n');
|
|
1347
2414
|
}
|
|
2415
|
+
// Return the report so non-UI callers (e.g. prepareConnectContext) can surface
|
|
2416
|
+
// skipped/user-owned files without re-deriving the skip logic (TEN-2107).
|
|
2417
|
+
return report;
|
|
1348
2418
|
}
|
|
2419
|
+
// WP-426 E3/E4: exported test-only surface (not part of the public CLI API).
|
|
2420
|
+
export const __test = { setupAuthoringPath, renameSurfaceForDormancy, restoreSurfaceFromDormant, loadAuthoringSyncState, MARKER };
|
|
1349
2421
|
//# sourceMappingURL=handshake.js.map
|