@crouton-kit/crouter 0.3.28 → 0.3.29
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/build-root.js +4 -2
- package/dist/builtin-memory/crouter-development/marketplaces.md +7 -6
- package/dist/builtin-memory/crouter-development/personas/base-prompt.md +1 -1
- package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +1 -1
- package/dist/builtin-memory/crouter-development/personas.md +1 -1
- package/dist/builtin-memory/crouter-development/plugins.md +20 -18
- package/dist/builtin-memory/design.md +1 -1
- package/dist/builtin-memory/development.md +1 -1
- package/dist/builtin-memory/internal/INDEX.md +1 -1
- package/dist/builtin-memory/internal/examples/INDEX.md +1 -1
- package/dist/builtin-memory/internal/examples/imessage-assistant.md +2 -2
- package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
- package/dist/builtin-memory/internal/storage-tiers.md +1 -1
- package/dist/builtin-memory/planning.md +1 -1
- package/dist/builtin-memory/spec.md +6 -4
- package/dist/builtin-personas/orchestration-kernel.md +4 -5
- package/dist/builtin-personas/runtime-base.md +5 -4
- package/dist/builtin-personas/spec/PERSONA.md +8 -4
- package/dist/builtin-personas/spec/orchestrator.md +5 -3
- package/dist/builtin-personas/spec/requirements/PERSONA.md +10 -0
- package/dist/builtin-views/canvas/core.mjs +586 -0
- package/dist/builtin-views/canvas/text.mjs +58 -0
- package/dist/builtin-views/canvas/tui.mjs +165 -0
- package/dist/builtin-views/canvas/web.jsx +120 -0
- package/dist/builtin-views/git-pr/core.mjs +673 -0
- package/dist/builtin-views/git-pr/text.mjs +84 -0
- package/dist/builtin-views/git-pr/tui.mjs +302 -0
- package/dist/builtin-views/git-pr/web.jsx +216 -0
- package/dist/builtin-views/inbox/_lib/render.mjs +1 -3
- package/dist/builtin-views/inbox/core.mjs +1273 -0
- package/dist/builtin-views/inbox/text.mjs +73 -0
- package/dist/builtin-views/inbox/tui.mjs +312 -0
- package/dist/builtin-views/inbox/web.jsx +188 -0
- package/dist/builtin-views/linkedin/core.mjs +906 -0
- package/dist/builtin-views/linkedin/text.mjs +69 -0
- package/dist/builtin-views/linkedin/tui.mjs +427 -0
- package/dist/builtin-views/linkedin/web.jsx +206 -0
- package/dist/builtin-views/workspace-sidebar/core.mjs +621 -0
- package/dist/builtin-views/workspace-sidebar/text.mjs +53 -0
- package/dist/builtin-views/workspace-sidebar/tui.mjs +142 -0
- package/dist/builtin-views/workspace-sidebar/web.jsx +109 -0
- package/dist/cli.js +3 -3
- package/dist/clients/attach/__tests__/action-parity.test.js +48 -0
- package/dist/clients/attach/__tests__/clipboard-image.test.js +76 -0
- package/dist/clients/attach/__tests__/editor-newline.test.js +57 -0
- package/dist/clients/attach/__tests__/git-info.test.js +31 -0
- package/dist/clients/attach/__tests__/onrequest-wired.test.js +20 -0
- package/dist/clients/attach/__tests__/slash-quit-copy.test.js +42 -0
- package/dist/clients/attach/__tests__/titled-editor.test.js +39 -0
- package/dist/clients/attach/attach-cmd.js +282 -46
- package/dist/clients/attach/auth-pickers.d.ts +9 -0
- package/dist/clients/attach/auth-pickers.js +194 -0
- package/dist/clients/attach/canvas-panels.d.ts +4 -2
- package/dist/clients/attach/canvas-panels.js +45 -17
- package/dist/clients/attach/chat-view.d.ts +27 -2
- package/dist/clients/attach/chat-view.js +98 -9
- package/dist/clients/attach/clipboard-image.d.ts +47 -8
- package/dist/clients/attach/clipboard-image.js +150 -26
- package/dist/clients/attach/clipboard-text.d.ts +3 -0
- package/dist/clients/attach/clipboard-text.js +39 -0
- package/dist/clients/attach/config-load.d.ts +54 -7
- package/dist/clients/attach/config-load.js +99 -3
- package/dist/clients/attach/context-message.d.ts +12 -0
- package/dist/clients/attach/context-message.js +72 -0
- package/dist/clients/attach/git-info.d.ts +20 -0
- package/dist/clients/attach/git-info.js +44 -0
- package/dist/clients/attach/graph-overlay.d.ts +13 -4
- package/dist/clients/attach/graph-overlay.js +59 -19
- package/dist/clients/attach/input-controller.d.ts +75 -13
- package/dist/clients/attach/input-controller.js +305 -67
- package/dist/clients/attach/pickers.d.ts +58 -0
- package/dist/clients/attach/pickers.js +171 -0
- package/dist/clients/attach/slash-commands.d.ts +22 -0
- package/dist/clients/attach/slash-commands.js +91 -25
- package/dist/clients/attach/titled-editor.d.ts +33 -0
- package/dist/clients/attach/titled-editor.js +84 -0
- package/dist/clients/attach/view-socket.d.ts +18 -1
- package/dist/clients/attach/view-socket.js +70 -1
- package/dist/clients/web/dev-server.d.ts +7 -0
- package/dist/clients/web/dev-server.js +59 -0
- package/dist/clients/web/events.d.ts +14 -0
- package/dist/clients/web/events.js +151 -0
- package/dist/clients/web/server.d.ts +18 -0
- package/dist/clients/web/server.js +450 -0
- package/dist/clients/web/web-cmd.d.ts +2 -0
- package/dist/clients/web/web-cmd.js +120 -0
- package/dist/commands/canvas.js +1 -2
- package/dist/commands/chord.js +1 -1
- package/dist/commands/dashboard.js +5 -1
- package/dist/commands/memory/__tests__/lint-schema.test.js +6 -6
- package/dist/commands/memory/lint.js +14 -2
- package/dist/commands/memory/read.js +2 -2
- package/dist/commands/memory/shared.d.ts +7 -1
- package/dist/commands/memory/shared.js +18 -3
- package/dist/commands/memory/write.js +8 -4
- package/dist/commands/memory.js +4 -4
- package/dist/commands/node.d.ts +1 -1
- package/dist/commands/node.js +64 -65
- package/dist/commands/pkg.js +1 -2
- package/dist/commands/revive.js +11 -3
- package/dist/commands/sys/sync.d.ts +1 -0
- package/dist/commands/sys/sync.js +187 -0
- package/dist/commands/sys.js +3 -2
- package/dist/commands/view-cycle.js +2 -2
- package/dist/commands/view-list.js +8 -8
- package/dist/commands/view-new.js +21 -17
- package/dist/commands/view-pick.js +1 -1
- package/dist/commands/view-run.js +35 -14
- package/dist/commands/view.js +8 -6
- package/dist/commands/workspace.d.ts +2 -0
- package/dist/commands/workspace.js +161 -0
- package/dist/core/__tests__/broker-double-spawn.test.d.ts +1 -0
- package/dist/core/__tests__/broker-double-spawn.test.js +143 -0
- package/dist/core/__tests__/broker-fork-seam.test.d.ts +1 -0
- package/dist/core/__tests__/broker-fork-seam.test.js +102 -0
- package/dist/core/__tests__/broker-sdk-wiring.test.js +2 -0
- package/dist/core/__tests__/child-death-wake.test.js +56 -111
- package/dist/core/__tests__/close.test.js +14 -20
- package/dist/core/__tests__/context-intro.test.js +19 -19
- package/dist/core/__tests__/daemon-boot.test.js +30 -23
- package/dist/core/__tests__/draw-style.test.js +1 -1
- package/dist/core/__tests__/error-stall-recycle.test.d.ts +1 -0
- package/dist/core/__tests__/error-stall-recycle.test.js +141 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +17 -1
- package/dist/core/__tests__/fixtures/fake-engine.js +22 -2
- package/dist/core/__tests__/fixtures/fake-pi-host.js +2 -2
- package/dist/core/__tests__/focuses.test.js +25 -13
- package/dist/core/__tests__/fork.test.js +22 -5
- package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +1 -0
- package/dist/core/__tests__/full/broker-control-preempt.test.js +61 -0
- package/dist/core/__tests__/full/broker-model-changed-broadcast.test.d.ts +1 -0
- package/dist/core/__tests__/full/broker-model-changed-broadcast.test.js +57 -0
- package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.d.ts +1 -0
- package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.js +80 -0
- package/dist/core/__tests__/full/broker-pane-resolution.test.js +90 -0
- package/dist/core/__tests__/full/cascade-close.test.js +22 -8
- package/dist/core/__tests__/full/detach-focus.test.js +66 -169
- package/dist/core/__tests__/helpers/harness.d.ts +6 -9
- package/dist/core/__tests__/helpers/harness.js +19 -36
- package/dist/core/__tests__/human-node-not-supervised.test.d.ts +1 -0
- package/dist/core/__tests__/human-node-not-supervised.test.js +83 -0
- package/dist/core/__tests__/lifecycle.test.js +1 -19
- package/dist/core/__tests__/live-mutation-verbs.test.js +5 -5
- package/dist/core/__tests__/memory-resolver.test.js +1 -1
- package/dist/core/__tests__/on-read-dedup-resume.test.js +2 -2
- package/dist/core/__tests__/refresh-stall-recycle.test.d.ts +1 -0
- package/dist/core/__tests__/refresh-stall-recycle.test.js +88 -0
- package/dist/core/__tests__/relaunch-root.test.d.ts +1 -0
- package/dist/core/__tests__/relaunch-root.test.js +114 -0
- package/dist/core/__tests__/reset.test.js +19 -79
- package/dist/core/__tests__/steer-note.test.js +9 -7
- package/dist/core/__tests__/tmux-surface.test.js +13 -7
- package/dist/core/bootstrap.d.ts +0 -3
- package/dist/core/bootstrap.js +1 -143
- package/dist/core/canvas/browse/__tests__/model.test.js +86 -1
- package/dist/core/canvas/browse/__tests__/render.test.js +2 -1
- package/dist/core/canvas/browse/app.js +131 -8
- package/dist/core/canvas/browse/model.d.ts +23 -5
- package/dist/core/canvas/browse/model.js +68 -13
- package/dist/core/canvas/browse/render.d.ts +7 -1
- package/dist/core/canvas/browse/render.js +178 -47
- package/dist/core/canvas/canvas.js +1 -1
- package/dist/core/canvas/focuses.d.ts +4 -4
- package/dist/core/canvas/focuses.js +7 -7
- package/dist/core/canvas/nav-model.js +8 -7
- package/dist/core/canvas/render.d.ts +68 -9
- package/dist/core/canvas/render.js +195 -61
- package/dist/core/canvas/types.d.ts +16 -23
- package/dist/core/config.js +1 -4
- package/dist/core/memory-resolver.js +43 -5
- package/dist/core/runtime/bearings.d.ts +1 -1
- package/dist/core/runtime/bearings.js +8 -8
- package/dist/core/runtime/branded-host.d.ts +16 -0
- package/dist/core/runtime/branded-host.js +127 -0
- package/dist/core/runtime/broker-protocol.d.ts +212 -6
- package/dist/core/runtime/broker.d.ts +4 -1
- package/dist/core/runtime/broker.js +494 -58
- package/dist/core/runtime/close.d.ts +12 -2
- package/dist/core/runtime/close.js +37 -16
- package/dist/core/runtime/front-door.js +6 -14
- package/dist/core/runtime/host.d.ts +14 -34
- package/dist/core/runtime/host.js +14 -50
- package/dist/core/runtime/launch.d.ts +12 -9
- package/dist/core/runtime/launch.js +27 -15
- package/dist/core/runtime/lifecycle.d.ts +1 -1
- package/dist/core/runtime/lifecycle.js +15 -19
- package/dist/core/runtime/nodes.d.ts +0 -55
- package/dist/core/runtime/nodes.js +6 -74
- package/dist/core/runtime/placement.d.ts +91 -335
- package/dist/core/runtime/placement.js +262 -840
- package/dist/core/runtime/promote.d.ts +2 -0
- package/dist/core/runtime/promote.js +24 -6
- package/dist/core/runtime/recap.d.ts +8 -0
- package/dist/core/runtime/recap.js +107 -0
- package/dist/core/runtime/recycle.js +25 -61
- package/dist/core/runtime/reset.d.ts +43 -37
- package/dist/core/runtime/reset.js +131 -218
- package/dist/core/runtime/revive.d.ts +9 -29
- package/dist/core/runtime/revive.js +40 -139
- package/dist/core/runtime/spawn.d.ts +15 -16
- package/dist/core/runtime/spawn.js +165 -194
- package/dist/core/runtime/surface-bg.d.ts +11 -0
- package/dist/core/runtime/surface-bg.js +68 -0
- package/dist/core/runtime/tmux.d.ts +13 -50
- package/dist/core/runtime/tmux.js +33 -87
- package/dist/core/scope.d.ts +1 -2
- package/dist/core/scope.js +1 -5
- package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.d.ts +1 -0
- package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.js +57 -0
- package/dist/core/skill-sync/builtins.d.ts +42 -0
- package/dist/core/skill-sync/builtins.js +112 -0
- package/dist/core/skill-sync/claude-plugins.d.ts +23 -0
- package/dist/core/skill-sync/claude-plugins.js +71 -0
- package/dist/core/skill-sync/engine.d.ts +42 -0
- package/dist/core/skill-sync/engine.js +633 -0
- package/dist/core/skill-sync/export.d.ts +23 -0
- package/dist/core/skill-sync/export.js +86 -0
- package/dist/core/skill-sync/manifest.d.ts +64 -0
- package/dist/core/skill-sync/manifest.js +181 -0
- package/dist/core/skill-sync/profile.d.ts +76 -0
- package/dist/core/skill-sync/profile.js +173 -0
- package/dist/core/skill-sync/snapshot.d.ts +57 -0
- package/dist/core/skill-sync/snapshot.js +120 -0
- package/dist/core/substrate/index.d.ts +1 -1
- package/dist/core/substrate/index.js +1 -1
- package/dist/core/substrate/injected-store.js +3 -3
- package/dist/core/substrate/on-read.js +68 -6
- package/dist/core/substrate/render.d.ts +8 -11
- package/dist/core/substrate/render.js +29 -43
- package/dist/core/substrate/schema.d.ts +10 -3
- package/dist/core/substrate/schema.js +6 -3
- package/dist/core/tui/host.d.ts +12 -4
- package/dist/core/tui/host.js +280 -149
- package/dist/core/view/bridge.d.ts +10 -0
- package/dist/core/view/bridge.js +31 -0
- package/dist/core/view/chrome.d.ts +9 -0
- package/dist/core/view/chrome.js +22 -0
- package/dist/core/view/contract.d.ts +171 -0
- package/dist/core/view/contract.js +23 -0
- package/dist/core/view/loader.d.ts +31 -0
- package/dist/core/view/loader.js +188 -0
- package/dist/core/view/transport-local.d.ts +7 -0
- package/dist/core/view/transport-local.js +70 -0
- package/dist/core/view/transport.d.ts +4 -0
- package/dist/core/view/transport.js +15 -0
- package/dist/daemon/crtrd.d.ts +38 -27
- package/dist/daemon/crtrd.js +303 -376
- package/dist/daemon/manage.js +6 -1
- package/dist/index.js +1 -1
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +28 -20
- package/dist/pi-extensions/canvas-context-intro.js +4 -4
- package/dist/pi-extensions/canvas-doc-substrate.js +16 -16
- package/dist/pi-extensions/canvas-nav.js +5 -0
- package/dist/pi-extensions/canvas-recap.d.ts +37 -0
- package/dist/pi-extensions/canvas-recap.js +236 -0
- package/dist/pi-extensions/canvas-resume.js +2 -1
- package/dist/pi-extensions/canvas-stophook.d.ts +14 -12
- package/dist/pi-extensions/canvas-stophook.js +87 -101
- package/dist/pi-extensions/canvas-view.js +2 -1
- package/dist/pi-extensions/widget-order-bus.d.ts +6 -0
- package/dist/pi-extensions/widget-order-bus.js +34 -0
- package/dist/prompts/view.d.ts +2 -2
- package/dist/prompts/view.js +145 -73
- package/dist/types.d.ts +1 -6
- package/dist/types.js +1 -3
- package/dist/web/ViewChrome.d.ts +7 -0
- package/dist/web/ViewChrome.js +28 -0
- package/dist/web/ViewPane.d.ts +39 -0
- package/dist/web/ViewPane.js +48 -0
- package/dist/web/index.d.ts +6 -0
- package/dist/web/index.js +16 -0
- package/dist/web/runtime.d.ts +39 -0
- package/dist/web/runtime.js +133 -0
- package/dist/web/states.d.ts +24 -0
- package/dist/web/states.js +24 -0
- package/dist/web/transport-http.d.ts +5 -0
- package/dist/web/transport-http.js +28 -0
- package/dist/web-client/assets/index-BUvQb4hR.css +2 -0
- package/dist/web-client/assets/index-ClLQXYAE.js +10 -0
- package/dist/web-client/index.html +13 -0
- package/package.json +20 -6
- package/dist/builtin-views/canvas/client.mjs +0 -303
- package/dist/builtin-views/canvas/view.mjs +0 -576
- package/dist/builtin-views/git-pr/client.mjs +0 -440
- package/dist/builtin-views/git-pr/view.mjs +0 -675
- package/dist/builtin-views/inbox/sources/gmail.mjs +0 -965
- package/dist/builtin-views/inbox/sources/linkedin.mjs +0 -427
- package/dist/builtin-views/inbox/view.mjs +0 -889
- package/dist/builtin-views/linkedin/client.mjs +0 -610
- package/dist/builtin-views/linkedin/view.mjs +0 -1171
- package/dist/commands/pkg/bridge.d.ts +0 -1
- package/dist/commands/pkg/bridge.js +0 -137
- package/dist/commands/skill/author.d.ts +0 -3
- package/dist/commands/skill/author.js +0 -140
- package/dist/commands/skill/shared.d.ts +0 -3
- package/dist/commands/skill/shared.js +0 -19
- package/dist/commands/skill.d.ts +0 -2
- package/dist/commands/skill.js +0 -21
- package/dist/commands/tmux-spread.d.ts +0 -2
- package/dist/commands/tmux-spread.js +0 -144
- package/dist/core/__tests__/full/daemon-liveness-pane.full.test.js +0 -292
- package/dist/core/__tests__/full/placement-focus.test.js +0 -309
- package/dist/core/__tests__/full/placement-reconcile.test.js +0 -212
- package/dist/core/__tests__/full/placement-revive.test.js +0 -238
- package/dist/core/__tests__/full/placement-teardown.test.js +0 -297
- package/dist/core/__tests__/home-session.test.js +0 -186
- package/dist/core/__tests__/relaunch.test.js +0 -335
- package/dist/core/bridge-map.d.ts +0 -19
- package/dist/core/bridge-map.js +0 -73
- package/dist/core/tui/contract.d.ts +0 -83
- package/dist/core/tui/contract.js +0 -8
- package/dist/core/tui/loader.d.ts +0 -16
- package/dist/core/tui/loader.js +0 -94
- package/dist/prompts/skill.d.ts +0 -2
- package/dist/prompts/skill.js +0 -650
- /package/dist/{core/__tests__/full/daemon-liveness-pane.full.test.d.ts → clients/attach/__tests__/action-parity.test.d.ts} +0 -0
- /package/dist/{core/__tests__/full/placement-focus.test.d.ts → clients/attach/__tests__/clipboard-image.test.d.ts} +0 -0
- /package/dist/{core/__tests__/full/placement-reconcile.test.d.ts → clients/attach/__tests__/editor-newline.test.d.ts} +0 -0
- /package/dist/{core/__tests__/full/placement-revive.test.d.ts → clients/attach/__tests__/git-info.test.d.ts} +0 -0
- /package/dist/{core/__tests__/full/placement-teardown.test.d.ts → clients/attach/__tests__/onrequest-wired.test.d.ts} +0 -0
- /package/dist/{core/__tests__/home-session.test.d.ts → clients/attach/__tests__/slash-quit-copy.test.d.ts} +0 -0
- /package/dist/{core/__tests__/relaunch.test.d.ts → clients/attach/__tests__/titled-editor.test.d.ts} +0 -0
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconcile engine (Phase P6) — the single, per-pair, direction-agnostic
|
|
3
|
+
* reconcile for bidirectional crtr ↔ Claude skill sync.
|
|
4
|
+
*
|
|
5
|
+
* `reconcilePair` is the ONLY reconcile routine (R-U3): there is no forward
|
|
6
|
+
* path + reverse path. Body and asset reconciliation is a symmetric 3-way merge
|
|
7
|
+
* (base = snapshot, ours = crtr, theirs = claude); frontmatter is the only
|
|
8
|
+
* asymmetric axis and all of that asymmetry lives in the translation profile
|
|
9
|
+
* (P5). The snapshot (P3) is the sole change-detector (R-U4): nothing here reads
|
|
10
|
+
* mtimes, fingerprints, or a stamp file.
|
|
11
|
+
*
|
|
12
|
+
* Strictness (R-U6): a conflict — overlapping body/asset merge, a both-sides
|
|
13
|
+
* non-equivalent frontmatter divergence, an asset deleted-on-one ∧ edited-on-the
|
|
14
|
+
* other, or a first-sync divergence with no common base — writes NOTHING for the
|
|
15
|
+
* pair, leaves both endpoints + the snapshot byte-untouched, and surfaces a
|
|
16
|
+
* git-style conflict report. There is no "best-effort" partial write and no
|
|
17
|
+
* silent side-pick.
|
|
18
|
+
*
|
|
19
|
+
* `--dry-run` (R-O1): every merge is computed and the would-be status returned,
|
|
20
|
+
* but no endpoint file, snapshot, or conflict report is touched.
|
|
21
|
+
*/
|
|
22
|
+
import { spawnSync } from 'node:child_process';
|
|
23
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, } from 'node:fs';
|
|
24
|
+
import { homedir, tmpdir } from 'node:os';
|
|
25
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
26
|
+
import { stringify as stringifyYaml } from 'yaml';
|
|
27
|
+
import { usage } from '../errors.js';
|
|
28
|
+
import { parseFrontmatterGeneric } from '../frontmatter.js';
|
|
29
|
+
import { findPluginByName } from '../resolver.js';
|
|
30
|
+
import { resolveClaudePluginInstallPath } from './claude-plugins.js';
|
|
31
|
+
import { findProjectScopeRoot, pluginMemoryDir, scopeMemoryDir, } from '../scope.js';
|
|
32
|
+
import { SKILLS_DIR, SKILL_ENTRY_FILE } from '../../types.js';
|
|
33
|
+
import { clearConflictReport, readSnapshot, snapshotDir, writeConflictReport, writeSnapshot, } from './snapshot.js';
|
|
34
|
+
/**
|
|
35
|
+
* Reconcile a single enrolled pair (R-E2). Resolves both endpoints, loads (or
|
|
36
|
+
* seeds) the snapshot, 3-way merges body + assets and translate-and-owns the
|
|
37
|
+
* frontmatter, then either writes through cleanly to both sides or reports the
|
|
38
|
+
* conflict and skips. Never throws on a *content* conflict (that is a reported
|
|
39
|
+
* outcome); throws only on a structural/configuration error (an unresolvable
|
|
40
|
+
* endpoint, both endpoints missing) so the caller surfaces it as a hard stop.
|
|
41
|
+
*/
|
|
42
|
+
export function reconcilePair(pair, profile, opts) {
|
|
43
|
+
const crtrDir = resolveCrtrBundleDir(pair.crtr);
|
|
44
|
+
const claudeDir = resolveClaudeBundleDir(pair.claude);
|
|
45
|
+
const crtr = readSide(crtrDir);
|
|
46
|
+
const claude = readSide(claudeDir);
|
|
47
|
+
const snapshot = readSnapshot(pair.id);
|
|
48
|
+
if (snapshot === null) {
|
|
49
|
+
return seed(pair, profile, opts, crtr, claude, crtrDir, claudeDir);
|
|
50
|
+
}
|
|
51
|
+
return reconcileWithBase(pair, profile, opts, snapshot, crtr, claude, crtrDir, claudeDir);
|
|
52
|
+
}
|
|
53
|
+
// ── Endpoint resolution (§Resolved-here-6) ───────────────────────────────────
|
|
54
|
+
/** crtr endpoint → its bundle dir `<memoryDir>/<name>/`. */
|
|
55
|
+
function resolveCrtrBundleDir(ep) {
|
|
56
|
+
let memoryDir;
|
|
57
|
+
if (ep.scope === 'plugin') {
|
|
58
|
+
const plugin = findPluginByName(ep.plugin);
|
|
59
|
+
if (!plugin) {
|
|
60
|
+
throw usage(`skill-sync: crtr plugin "${ep.plugin}" not installed`);
|
|
61
|
+
}
|
|
62
|
+
memoryDir = pluginMemoryDir(plugin);
|
|
63
|
+
}
|
|
64
|
+
else if (ep.scope === 'user' || ep.scope === 'project') {
|
|
65
|
+
memoryDir = scopeMemoryDir(ep.scope);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
throw usage(`skill-sync: scope "${ep.scope}" is not valid for a crtr endpoint ` +
|
|
69
|
+
`(use user, project, or plugin)`);
|
|
70
|
+
}
|
|
71
|
+
if (!memoryDir) {
|
|
72
|
+
throw usage(`skill-sync: no crtr ${ep.scope} memory dir available for "${ep.name}"`);
|
|
73
|
+
}
|
|
74
|
+
return join(memoryDir, ep.name);
|
|
75
|
+
}
|
|
76
|
+
/** Claude endpoint → its bundle dir `<skillsRoot>/<name>/`. The per-scope Claude
|
|
77
|
+
* skills roots: user `~/.claude/skills`, project `<proj>/.claude/skills`, and
|
|
78
|
+
* `claude-plugin` the owning plugin's actual install path
|
|
79
|
+
* `<installPath>/skills` (resolved from Claude's own registry, in place — no
|
|
80
|
+
* user-scope copy). */
|
|
81
|
+
function resolveClaudeBundleDir(ep) {
|
|
82
|
+
let skillsRoot;
|
|
83
|
+
if (ep.scope === 'user') {
|
|
84
|
+
skillsRoot = join(homedir(), '.claude', SKILLS_DIR);
|
|
85
|
+
}
|
|
86
|
+
else if (ep.scope === 'project') {
|
|
87
|
+
const projScopeRoot = findProjectScopeRoot();
|
|
88
|
+
if (!projScopeRoot) {
|
|
89
|
+
throw usage(`skill-sync: no project scope for Claude endpoint "${ep.name}"`);
|
|
90
|
+
}
|
|
91
|
+
// `<proj>/.crouter` → the project dir is its parent; Claude skills sit at
|
|
92
|
+
// `<proj>/.claude/skills`.
|
|
93
|
+
skillsRoot = join(dirname(projScopeRoot), '.claude', SKILLS_DIR);
|
|
94
|
+
}
|
|
95
|
+
else if (ep.scope === 'claude-plugin') {
|
|
96
|
+
skillsRoot = join(resolveClaudePluginInstallPath(ep.plugin), SKILLS_DIR);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
throw usage(`skill-sync: scope "${ep.scope}" is not valid for a Claude endpoint ` +
|
|
100
|
+
`(use user, project, or claude-plugin)`);
|
|
101
|
+
}
|
|
102
|
+
return join(skillsRoot, ep.name);
|
|
103
|
+
}
|
|
104
|
+
const EMPTY_SIDE = {
|
|
105
|
+
exists: false,
|
|
106
|
+
rawFrontmatter: '',
|
|
107
|
+
frontmatter: null,
|
|
108
|
+
body: '',
|
|
109
|
+
assets: new Map(),
|
|
110
|
+
};
|
|
111
|
+
function readSide(bundleDir) {
|
|
112
|
+
const skillPath = join(bundleDir, SKILL_ENTRY_FILE);
|
|
113
|
+
if (!existsSync(skillPath))
|
|
114
|
+
return { ...EMPTY_SIDE, assets: new Map() };
|
|
115
|
+
const parsed = parseFrontmatterGeneric(readFileSync(skillPath, 'utf8'));
|
|
116
|
+
return {
|
|
117
|
+
exists: true,
|
|
118
|
+
rawFrontmatter: parsed.raw,
|
|
119
|
+
frontmatter: parsed.data,
|
|
120
|
+
body: parsed.body,
|
|
121
|
+
assets: readBundleAssets(bundleDir),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** Every file under `bundleDir` EXCEPT the top-level SKILL.md, keyed by POSIX
|
|
125
|
+
* relpath. Mirrors the snapshot store's asset-map key shape. */
|
|
126
|
+
function readBundleAssets(bundleDir) {
|
|
127
|
+
const out = new Map();
|
|
128
|
+
const walk = (cur) => {
|
|
129
|
+
let entries;
|
|
130
|
+
try {
|
|
131
|
+
entries = readdirSync(cur, { withFileTypes: true });
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
for (const entry of entries) {
|
|
137
|
+
const full = join(cur, entry.name);
|
|
138
|
+
if (entry.isDirectory()) {
|
|
139
|
+
walk(full);
|
|
140
|
+
}
|
|
141
|
+
else if (entry.isFile()) {
|
|
142
|
+
const rel = relative(bundleDir, full).split(sep).join('/');
|
|
143
|
+
if (rel === SKILL_ENTRY_FILE)
|
|
144
|
+
continue; // the doc, not an asset
|
|
145
|
+
out.set(rel, readFileSync(full));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
walk(bundleDir);
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
// ── Frontmatter field access ─────────────────────────────────────────────────
|
|
153
|
+
const CRTR_WHENWHY = 'when-and-why-to-read';
|
|
154
|
+
const CRTR_SHORTFORM = 'short-form';
|
|
155
|
+
const CLAUDE_DESC = 'description';
|
|
156
|
+
function strField(rec, key) {
|
|
157
|
+
if (!rec)
|
|
158
|
+
return undefined;
|
|
159
|
+
const v = rec[key];
|
|
160
|
+
if (v === undefined || v === null)
|
|
161
|
+
return undefined;
|
|
162
|
+
return typeof v === 'string' ? v : String(v);
|
|
163
|
+
}
|
|
164
|
+
function readCrtrFm(side) {
|
|
165
|
+
return {
|
|
166
|
+
whenAndWhy: strField(side.frontmatter, CRTR_WHENWHY),
|
|
167
|
+
shortForm: strField(side.frontmatter, CRTR_SHORTFORM),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
function readClaudeDesc(side) {
|
|
171
|
+
return strField(side.frontmatter, CLAUDE_DESC);
|
|
172
|
+
}
|
|
173
|
+
function sameStr(a, b) {
|
|
174
|
+
return (a ?? undefined) === (b ?? undefined);
|
|
175
|
+
}
|
|
176
|
+
function sameCrtrFm(a, b) {
|
|
177
|
+
return sameStr(a.whenAndWhy, b.whenAndWhy) && sameStr(a.shortForm, b.shortForm);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* 3-way the single translatable concept (description ↔ when-and-why/short-form)
|
|
181
|
+
* against the recorded `meta.json` base (R-E4). Owned fields are never touched
|
|
182
|
+
* here — each side reads them through, so they appear in neither input nor
|
|
183
|
+
* output (R-X2). Equivalence (OD-1) is round-trip equality through the canonical
|
|
184
|
+
* Claude side.
|
|
185
|
+
*/
|
|
186
|
+
function resolveFrontmatter(profile, meta, crtrCur, claudeCur) {
|
|
187
|
+
const baseCrtr = {
|
|
188
|
+
whenAndWhy: meta.crtrFrontmatter.whenAndWhy,
|
|
189
|
+
shortForm: meta.crtrFrontmatter.shortForm,
|
|
190
|
+
};
|
|
191
|
+
const baseClaude = meta.claudeFrontmatter.description;
|
|
192
|
+
const crtrChanged = !sameCrtrFm(crtrCur, baseCrtr);
|
|
193
|
+
const claudeChanged = !sameStr(claudeCur, baseClaude);
|
|
194
|
+
if (!crtrChanged && !claudeChanged) {
|
|
195
|
+
return { crtr: crtrCur, claudeDesc: claudeCur };
|
|
196
|
+
}
|
|
197
|
+
if (crtrChanged && !claudeChanged) {
|
|
198
|
+
// crtr is the sole edit → translate it onto the Claude side.
|
|
199
|
+
return { crtr: crtrCur, claudeDesc: translateCrtrToClaude(profile, crtrCur) };
|
|
200
|
+
}
|
|
201
|
+
if (!crtrChanged && claudeChanged) {
|
|
202
|
+
// Claude is the sole edit → translate it onto the crtr side.
|
|
203
|
+
return { crtr: translateClaudeToCrtr(profile, claudeCur), claudeDesc: claudeCur };
|
|
204
|
+
}
|
|
205
|
+
// Both sides changed — equivalent iff each translated to the canonical Claude
|
|
206
|
+
// side yields a byte-identical string (§Resolved-here-5 / OD-1).
|
|
207
|
+
const crtrAsClaude = translateCrtrToClaude(profile, crtrCur);
|
|
208
|
+
const claudeAsClaude = translateCrtrToClaude(profile, translateClaudeToCrtr(profile, claudeCur));
|
|
209
|
+
if (crtrAsClaude === claudeAsClaude) {
|
|
210
|
+
// Equivalent → no real divergence; keep each side's current value (EC-8).
|
|
211
|
+
return { crtr: crtrCur, claudeDesc: claudeCur };
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
crtr: crtrCur,
|
|
215
|
+
claudeDesc: claudeCur,
|
|
216
|
+
conflict: `frontmatter "${CLAUDE_DESC}" / "${CRTR_WHENWHY}" changed on both sides to ` +
|
|
217
|
+
`non-equivalent values:\n` +
|
|
218
|
+
` crtr when-and-why-to-read: ${JSON.stringify(crtrCur.whenAndWhy ?? '')}\n` +
|
|
219
|
+
` crtr short-form: ${JSON.stringify(crtrCur.shortForm ?? '')}\n` +
|
|
220
|
+
` claude description: ${JSON.stringify(claudeCur ?? '')}`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
function translateCrtrToClaude(profile, fm) {
|
|
224
|
+
return profile.crtrToClaude.description(fm.whenAndWhy ?? '', fm.shortForm);
|
|
225
|
+
}
|
|
226
|
+
function translateClaudeToCrtr(profile, desc) {
|
|
227
|
+
const { whenAndWhy, shortForm } = profile.claudeToCrtr.description(desc ?? '');
|
|
228
|
+
return { whenAndWhy, shortForm };
|
|
229
|
+
}
|
|
230
|
+
// ── Seeding (first sync — no snapshot) ───────────────────────────────────────
|
|
231
|
+
function seed(pair, profile, opts, crtr, claude, crtrDir, claudeDir) {
|
|
232
|
+
if (crtr.exists && claude.exists) {
|
|
233
|
+
const sharedIdentical = crtr.body === claude.body && assetsEqual(crtr.assets, claude.assets);
|
|
234
|
+
if (sharedIdentical) {
|
|
235
|
+
// R-S1 — endpoints already identical; seed the base, no merge, no-op.
|
|
236
|
+
const meta = buildMeta(readCrtrFm(crtr), readClaudeDesc(claude));
|
|
237
|
+
if (!opts.dryRun) {
|
|
238
|
+
writeSnapshot(pair.id, { body: crtr.body, assets: crtr.assets, meta });
|
|
239
|
+
clearConflictReport(pair.id);
|
|
240
|
+
}
|
|
241
|
+
return { id: pair.id, status: 'noop', wrote: 0 };
|
|
242
|
+
}
|
|
243
|
+
// R-S2 — divergent with no common ancestor → initial conflict, no write.
|
|
244
|
+
if (!opts.dryRun) {
|
|
245
|
+
writeConflictReport(pair.id, buildReport(pair.id, [
|
|
246
|
+
'first sync: both endpoints exist but their shared content differs, ' +
|
|
247
|
+
'and there is no snapshot base to merge against. Reconcile the two ' +
|
|
248
|
+
'sides by hand (make them identical) and re-run.',
|
|
249
|
+
]));
|
|
250
|
+
}
|
|
251
|
+
return { id: pair.id, status: 'conflict', wrote: 0 };
|
|
252
|
+
}
|
|
253
|
+
if (!crtr.exists && !claude.exists) {
|
|
254
|
+
throw usage(`skill-sync: pair "${pair.id}" has no skill on either side ` +
|
|
255
|
+
`(crtr ${join(crtrDir, SKILL_ENTRY_FILE)} / claude ${join(claudeDir, SKILL_ENTRY_FILE)})`);
|
|
256
|
+
}
|
|
257
|
+
// R-S3 — one-sided bootstrap: materialize the existing side onto the other,
|
|
258
|
+
// translating the new side's frontmatter, and seed the base.
|
|
259
|
+
const crtrIsSource = crtr.exists;
|
|
260
|
+
const source = crtrIsSource ? crtr : claude;
|
|
261
|
+
let crtrFm;
|
|
262
|
+
let claudeDesc;
|
|
263
|
+
if (crtrIsSource) {
|
|
264
|
+
crtrFm = readCrtrFm(source);
|
|
265
|
+
claudeDesc = translateCrtrToClaude(profile, crtrFm);
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
claudeDesc = readClaudeDesc(source);
|
|
269
|
+
crtrFm = translateClaudeToCrtr(profile, claudeDesc);
|
|
270
|
+
}
|
|
271
|
+
const desired = {
|
|
272
|
+
body: source.body,
|
|
273
|
+
assets: source.assets,
|
|
274
|
+
crtrFmInner: frontmatterInner(crtr, crtrFm, true, pair.crtr),
|
|
275
|
+
claudeFmInner: frontmatterInner(claude, claudeDesc, false, pair.claude),
|
|
276
|
+
meta: buildMeta(crtrFm, claudeDesc),
|
|
277
|
+
};
|
|
278
|
+
const wrote = applyAndSnapshot(pair, opts, desired, crtrDir, claudeDir, null);
|
|
279
|
+
return { id: pair.id, status: 'synced', wrote };
|
|
280
|
+
}
|
|
281
|
+
// ── Reconcile against an existing base ───────────────────────────────────────
|
|
282
|
+
function reconcileWithBase(pair, profile, opts, snapshot, crtr, claude, crtrDir, claudeDir) {
|
|
283
|
+
// Whole-bundle presence first: a side whose SKILL.md vanished since the base
|
|
284
|
+
// is a deletion. Propagate it iff the surviving side is unchanged vs base;
|
|
285
|
+
// otherwise it is a delete ∧ edit conflict.
|
|
286
|
+
if (!crtr.exists || !claude.exists) {
|
|
287
|
+
return reconcileDeletion(pair, opts, snapshot, crtr, claude, crtrDir, claudeDir);
|
|
288
|
+
}
|
|
289
|
+
const conflicts = [];
|
|
290
|
+
// Body — both sides present, base is the snapshot body.
|
|
291
|
+
const bodyRes = mergeContent('SKILL.md (body)', Buffer.from(snapshot.body, 'utf8'), Buffer.from(crtr.body, 'utf8'), Buffer.from(claude.body, 'utf8'));
|
|
292
|
+
let mergedBody = snapshot.body;
|
|
293
|
+
if (bodyRes.kind === 'conflict')
|
|
294
|
+
conflicts.push(bodyRes.detail);
|
|
295
|
+
else if (bodyRes.kind === 'present')
|
|
296
|
+
mergedBody = bodyRes.content.toString('utf8');
|
|
297
|
+
// Assets — base-union, per-file 3-way.
|
|
298
|
+
const mergedAssets = new Map();
|
|
299
|
+
for (const rel of unionKeys(snapshot.assets, crtr.assets, claude.assets)) {
|
|
300
|
+
const res = mergeSlot(rel, snapshot.assets.get(rel), crtr.assets.get(rel), claude.assets.get(rel));
|
|
301
|
+
if (res.kind === 'present')
|
|
302
|
+
mergedAssets.set(rel, res.content);
|
|
303
|
+
else if (res.kind === 'conflict')
|
|
304
|
+
conflicts.push(res.detail);
|
|
305
|
+
// 'absent' → deleted on both / clean delete → omit from the merged set.
|
|
306
|
+
}
|
|
307
|
+
// Frontmatter — the single translatable concept, 3-way vs meta.json.
|
|
308
|
+
const fm = resolveFrontmatter(profile, snapshot.meta, readCrtrFm(crtr), readClaudeDesc(claude));
|
|
309
|
+
if (fm.conflict)
|
|
310
|
+
conflicts.push(fm.conflict);
|
|
311
|
+
if (conflicts.length > 0) {
|
|
312
|
+
if (!opts.dryRun)
|
|
313
|
+
writeConflictReport(pair.id, buildReport(pair.id, conflicts));
|
|
314
|
+
return { id: pair.id, status: 'conflict', wrote: 0 };
|
|
315
|
+
}
|
|
316
|
+
const desired = {
|
|
317
|
+
body: mergedBody,
|
|
318
|
+
assets: mergedAssets,
|
|
319
|
+
crtrFmInner: frontmatterInner(crtr, fm.crtr, true, pair.crtr),
|
|
320
|
+
claudeFmInner: frontmatterInner(claude, fm.claudeDesc, false, pair.claude),
|
|
321
|
+
meta: buildMeta(fm.crtr, fm.claudeDesc),
|
|
322
|
+
};
|
|
323
|
+
// Decide synced vs no-op by comparing the desired end-state to what is already
|
|
324
|
+
// on both endpoints AND the snapshot (R-S4 idempotency).
|
|
325
|
+
const counts = applyEndpoints(opts.dryRun, desired, crtrDir, claudeDir, /*write*/ !opts.dryRun);
|
|
326
|
+
const snapDiffers = snapshotDiffers(snapshot, desired);
|
|
327
|
+
const changed = counts.writes > 0 || counts.deletions > 0 || snapDiffers;
|
|
328
|
+
if (!changed) {
|
|
329
|
+
if (!opts.dryRun)
|
|
330
|
+
clearConflictReport(pair.id); // OD-4 — clear any stale report
|
|
331
|
+
return { id: pair.id, status: 'noop', wrote: 0 };
|
|
332
|
+
}
|
|
333
|
+
if (!opts.dryRun) {
|
|
334
|
+
writeSnapshot(pair.id, { body: desired.body, assets: desired.assets, meta: desired.meta });
|
|
335
|
+
clearConflictReport(pair.id); // OD-4
|
|
336
|
+
}
|
|
337
|
+
// `wrote` counts ACTUAL endpoint files written — 0 under --dry-run, even
|
|
338
|
+
// though `counts.writes` above carries the would-be total used for the
|
|
339
|
+
// synced/noop decision (contract: `sys sync -h` promises 0 under --dry-run).
|
|
340
|
+
return { id: pair.id, status: 'synced', wrote: opts.dryRun ? 0 : counts.writes };
|
|
341
|
+
}
|
|
342
|
+
/** Handle the case where at least one side's SKILL.md is gone in a 3-way. */
|
|
343
|
+
function reconcileDeletion(pair, opts, snapshot, crtr, claude, crtrDir, claudeDir) {
|
|
344
|
+
// Both gone → the skill was removed everywhere; clear the base, no-op.
|
|
345
|
+
if (!crtr.exists && !claude.exists) {
|
|
346
|
+
if (!opts.dryRun) {
|
|
347
|
+
rmSync(snapshotDir(pair.id), { recursive: true, force: true });
|
|
348
|
+
clearConflictReport(pair.id);
|
|
349
|
+
}
|
|
350
|
+
return { id: pair.id, status: 'noop', wrote: 0 };
|
|
351
|
+
}
|
|
352
|
+
const surviving = crtr.exists ? crtr : claude;
|
|
353
|
+
const survivingUnchanged = surviving.body === snapshot.body && assetsEqual(surviving.assets, snapshot.assets);
|
|
354
|
+
const survivingDir = crtr.exists ? crtrDir : claudeDir;
|
|
355
|
+
const deletedLabel = crtr.exists ? 'claude' : 'crtr';
|
|
356
|
+
if (survivingUnchanged) {
|
|
357
|
+
// Clean deletion → remove the surviving bundle and the base.
|
|
358
|
+
if (!opts.dryRun) {
|
|
359
|
+
rmSync(survivingDir, { recursive: true, force: true });
|
|
360
|
+
rmSync(snapshotDir(pair.id), { recursive: true, force: true });
|
|
361
|
+
clearConflictReport(pair.id);
|
|
362
|
+
}
|
|
363
|
+
return { id: pair.id, status: 'synced', wrote: 0 };
|
|
364
|
+
}
|
|
365
|
+
// delete ∧ edit → conflict, no write.
|
|
366
|
+
if (!opts.dryRun) {
|
|
367
|
+
writeConflictReport(pair.id, buildReport(pair.id, [
|
|
368
|
+
`the skill was deleted on the ${deletedLabel} side but edited on the other ` +
|
|
369
|
+
`side since the last sync. Resolve by hand (restore or finish the deletion) ` +
|
|
370
|
+
`and re-run.`,
|
|
371
|
+
]));
|
|
372
|
+
}
|
|
373
|
+
return { id: pair.id, status: 'conflict', wrote: 0 };
|
|
374
|
+
}
|
|
375
|
+
/** Apply the desired state to both endpoints (when `write`), counting files
|
|
376
|
+
* written and deleted across both sides. With `write=false` it only computes
|
|
377
|
+
* the counts (the dry-run path) and touches nothing. */
|
|
378
|
+
function applyEndpoints(_dryRun, desired, crtrDir, claudeDir, write) {
|
|
379
|
+
const a = applyEndpoint(crtrDir, desired.crtrFmInner, desired.body, desired.assets, write);
|
|
380
|
+
const b = applyEndpoint(claudeDir, desired.claudeFmInner, desired.body, desired.assets, write);
|
|
381
|
+
return { writes: a.writes + b.writes, deletions: a.deletions + b.deletions };
|
|
382
|
+
}
|
|
383
|
+
function applyEndpoint(bundleDir, fmInner, body, assets, write) {
|
|
384
|
+
let writes = 0;
|
|
385
|
+
let deletions = 0;
|
|
386
|
+
// SKILL.md
|
|
387
|
+
const skillPath = join(bundleDir, SKILL_ENTRY_FILE);
|
|
388
|
+
const desiredSkill = renderSkill(fmInner, body);
|
|
389
|
+
const curSkill = existsSync(skillPath) ? readFileSync(skillPath, 'utf8') : null;
|
|
390
|
+
if (curSkill !== desiredSkill) {
|
|
391
|
+
writes++;
|
|
392
|
+
if (write) {
|
|
393
|
+
mkdirSync(bundleDir, { recursive: true });
|
|
394
|
+
writeFileSync(skillPath, desiredSkill, 'utf8');
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// Assets: remove on-disk strays not in the desired set, then write the rest.
|
|
398
|
+
const curAssets = existsSync(bundleDir) ? readBundleAssets(bundleDir) : new Map();
|
|
399
|
+
for (const rel of curAssets.keys()) {
|
|
400
|
+
if (!assets.has(rel)) {
|
|
401
|
+
deletions++;
|
|
402
|
+
if (write)
|
|
403
|
+
rmSync(join(bundleDir, ...rel.split('/')), { force: true });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
for (const [rel, buf] of assets) {
|
|
407
|
+
const cur = curAssets.get(rel);
|
|
408
|
+
if (!cur || !cur.equals(buf)) {
|
|
409
|
+
writes++;
|
|
410
|
+
if (write) {
|
|
411
|
+
const full = join(bundleDir, ...rel.split('/'));
|
|
412
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
413
|
+
writeFileSync(full, buf);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return { writes, deletions };
|
|
418
|
+
}
|
|
419
|
+
/** Seed/materialize helper: apply both endpoints and the snapshot, returning the
|
|
420
|
+
* endpoint files written. `_priorSnapshot` is unused today but documents that
|
|
421
|
+
* callers pass null on a fresh seed. */
|
|
422
|
+
function applyAndSnapshot(pair, opts, desired, crtrDir, claudeDir, _priorSnapshot) {
|
|
423
|
+
const counts = applyEndpoints(opts.dryRun, desired, crtrDir, claudeDir, !opts.dryRun);
|
|
424
|
+
if (!opts.dryRun) {
|
|
425
|
+
writeSnapshot(pair.id, { body: desired.body, assets: desired.assets, meta: desired.meta });
|
|
426
|
+
clearConflictReport(pair.id);
|
|
427
|
+
}
|
|
428
|
+
// Actual writes only — 0 under --dry-run (see reconcileWithBase note).
|
|
429
|
+
return opts.dryRun ? 0 : counts.writes;
|
|
430
|
+
}
|
|
431
|
+
/** A SKILL.md document = its frontmatter block + shared body. */
|
|
432
|
+
function renderSkill(fmInner, body) {
|
|
433
|
+
if (fmInner === '')
|
|
434
|
+
return body;
|
|
435
|
+
return `---\n${fmInner}\n---\n${body}`;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* The inner YAML for a side's SKILL.md frontmatter. A side is reserialized ONLY
|
|
439
|
+
* when its translatable value actually changes (or it is being materialized);
|
|
440
|
+
* otherwise its original raw frontmatter block is preserved byte-for-byte so a
|
|
441
|
+
* re-run never normalizes hand-authored formatting into a phantom diff (R-S4).
|
|
442
|
+
*/
|
|
443
|
+
function frontmatterInner(side, resolved, isCrtr, ep) {
|
|
444
|
+
if (isCrtr) {
|
|
445
|
+
const target = resolved;
|
|
446
|
+
if (side.exists && sameCrtrFm(target, readCrtrFm(side))) {
|
|
447
|
+
return side.rawFrontmatter; // unchanged → verbatim
|
|
448
|
+
}
|
|
449
|
+
return buildCrtrFrontmatter(side, target);
|
|
450
|
+
}
|
|
451
|
+
const desc = resolved;
|
|
452
|
+
if (side.exists && sameStr(desc, readClaudeDesc(side))) {
|
|
453
|
+
return side.rawFrontmatter; // unchanged → verbatim
|
|
454
|
+
}
|
|
455
|
+
return buildClaudeFrontmatter(side, desc, ep);
|
|
456
|
+
}
|
|
457
|
+
/** Reconstruct the crtr frontmatter record with the resolved translatable
|
|
458
|
+
* fields set, preserving every existing (owned/other) field. A freshly
|
|
459
|
+
* materialized bundle defaults `kind: knowledge` — the valid substrate kind a
|
|
460
|
+
* crtr-side doc must carry (the merged skill+reference kind). */
|
|
461
|
+
function buildCrtrFrontmatter(side, fm) {
|
|
462
|
+
const rec = side.frontmatter ? { ...side.frontmatter } : {};
|
|
463
|
+
if (!side.exists && rec.kind === undefined)
|
|
464
|
+
rec.kind = 'knowledge';
|
|
465
|
+
setOrDelete(rec, CRTR_WHENWHY, fm.whenAndWhy);
|
|
466
|
+
setOrDelete(rec, CRTR_SHORTFORM, fm.shortForm);
|
|
467
|
+
return serializeInner(rec);
|
|
468
|
+
}
|
|
469
|
+
/** Reconstruct the Claude frontmatter record with the resolved description set,
|
|
470
|
+
* preserving every existing (owned/other) field. A freshly materialized bundle
|
|
471
|
+
* carries `name: <ep.name>`. */
|
|
472
|
+
function buildClaudeFrontmatter(side, desc, ep) {
|
|
473
|
+
const rec = side.frontmatter ? { ...side.frontmatter } : {};
|
|
474
|
+
if (!side.exists && rec.name === undefined)
|
|
475
|
+
rec.name = ep.name;
|
|
476
|
+
setOrDelete(rec, CLAUDE_DESC, desc);
|
|
477
|
+
return serializeInner(rec);
|
|
478
|
+
}
|
|
479
|
+
function setOrDelete(rec, key, value) {
|
|
480
|
+
if (value === undefined)
|
|
481
|
+
delete rec[key];
|
|
482
|
+
else
|
|
483
|
+
rec[key] = value;
|
|
484
|
+
}
|
|
485
|
+
/** Serialize a frontmatter record to inner YAML (no trailing newline). */
|
|
486
|
+
function serializeInner(rec) {
|
|
487
|
+
if (Object.keys(rec).length === 0)
|
|
488
|
+
return '';
|
|
489
|
+
return stringifyYaml(rec).replace(/\n$/, '');
|
|
490
|
+
}
|
|
491
|
+
function buildMeta(crtr, claudeDesc) {
|
|
492
|
+
const crtrFrontmatter = {};
|
|
493
|
+
if (crtr.whenAndWhy !== undefined)
|
|
494
|
+
crtrFrontmatter.whenAndWhy = crtr.whenAndWhy;
|
|
495
|
+
if (crtr.shortForm !== undefined)
|
|
496
|
+
crtrFrontmatter.shortForm = crtr.shortForm;
|
|
497
|
+
const claudeFrontmatter = {};
|
|
498
|
+
if (claudeDesc !== undefined)
|
|
499
|
+
claudeFrontmatter.description = claudeDesc;
|
|
500
|
+
return { syncedAt: new Date().toISOString(), crtrFrontmatter, claudeFrontmatter };
|
|
501
|
+
}
|
|
502
|
+
/** True iff the desired shared content or translatable values differ from the
|
|
503
|
+
* snapshot base (syncedAt is bookkeeping and intentionally ignored). */
|
|
504
|
+
function snapshotDiffers(snapshot, desired) {
|
|
505
|
+
if (snapshot.body !== desired.body)
|
|
506
|
+
return true;
|
|
507
|
+
if (!assetsEqual(snapshot.assets, desired.assets))
|
|
508
|
+
return true;
|
|
509
|
+
const m = snapshot.meta;
|
|
510
|
+
if (!sameStr(m.crtrFrontmatter.whenAndWhy, desired.meta.crtrFrontmatter.whenAndWhy))
|
|
511
|
+
return true;
|
|
512
|
+
if (!sameStr(m.crtrFrontmatter.shortForm, desired.meta.crtrFrontmatter.shortForm))
|
|
513
|
+
return true;
|
|
514
|
+
if (!sameStr(m.claudeFrontmatter.description, desired.meta.claudeFrontmatter.description))
|
|
515
|
+
return true;
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
/** File-level 3-way for one asset relpath, resolving presence by the base-union
|
|
519
|
+
* rule (add-both / delete-both / delete ∧ edit → conflict) then content. */
|
|
520
|
+
function mergeSlot(rel, base, ours, theirs) {
|
|
521
|
+
if (base) {
|
|
522
|
+
if (!ours && !theirs)
|
|
523
|
+
return { kind: 'absent' }; // deleted both sides
|
|
524
|
+
if (!ours) {
|
|
525
|
+
// deleted on crtr
|
|
526
|
+
if (theirs.equals(base))
|
|
527
|
+
return { kind: 'absent' }; // untouched other → delete both
|
|
528
|
+
return { kind: 'conflict', detail: `asset ${rel}: deleted on crtr, edited on claude` };
|
|
529
|
+
}
|
|
530
|
+
if (!theirs) {
|
|
531
|
+
// deleted on claude
|
|
532
|
+
if (ours.equals(base))
|
|
533
|
+
return { kind: 'absent' };
|
|
534
|
+
return { kind: 'conflict', detail: `asset ${rel}: deleted on claude, edited on crtr` };
|
|
535
|
+
}
|
|
536
|
+
return mergeContent(`asset ${rel}`, base, ours, theirs);
|
|
537
|
+
}
|
|
538
|
+
// Not in base.
|
|
539
|
+
if (!ours && !theirs)
|
|
540
|
+
return { kind: 'absent' };
|
|
541
|
+
if (ours && !theirs)
|
|
542
|
+
return { kind: 'present', content: ours }; // added on crtr
|
|
543
|
+
if (!ours && theirs)
|
|
544
|
+
return { kind: 'present', content: theirs }; // added on claude
|
|
545
|
+
if (ours.equals(theirs))
|
|
546
|
+
return { kind: 'present', content: ours }; // added identically
|
|
547
|
+
// Both added divergently → merge against an empty base.
|
|
548
|
+
return mergeContent(`asset ${rel}`, Buffer.alloc(0), ours, theirs);
|
|
549
|
+
}
|
|
550
|
+
/** Content 3-way for a slot present on all three of base/ours/theirs. Text →
|
|
551
|
+
* `git merge-file`; binary (NUL in first 8 KB, §Resolved-here-3) → whole-file
|
|
552
|
+
* identity 3-way, never line-merged (OD-2). */
|
|
553
|
+
function mergeContent(label, base, ours, theirs) {
|
|
554
|
+
if (base.equals(ours) && base.equals(theirs))
|
|
555
|
+
return { kind: 'present', content: ours };
|
|
556
|
+
if (base.equals(ours))
|
|
557
|
+
return { kind: 'present', content: theirs }; // only theirs changed
|
|
558
|
+
if (base.equals(theirs))
|
|
559
|
+
return { kind: 'present', content: ours }; // only ours changed
|
|
560
|
+
if (ours.equals(theirs))
|
|
561
|
+
return { kind: 'present', content: ours }; // both → same value
|
|
562
|
+
if (isBinary(base) || isBinary(ours) || isBinary(theirs)) {
|
|
563
|
+
return { kind: 'conflict', detail: `${label}: binary content changed on both sides` };
|
|
564
|
+
}
|
|
565
|
+
const { merged, conflicts } = mergeFile3(base, ours, theirs);
|
|
566
|
+
if (conflicts) {
|
|
567
|
+
return { kind: 'conflict', detail: `${label}:\n${merged.toString('utf8')}` };
|
|
568
|
+
}
|
|
569
|
+
return { kind: 'present', content: merged };
|
|
570
|
+
}
|
|
571
|
+
/** An asset is binary iff a NUL byte appears in its first 8 KB (§Resolved-here-3). */
|
|
572
|
+
function isBinary(buf) {
|
|
573
|
+
const n = Math.min(buf.length, 8192);
|
|
574
|
+
for (let i = 0; i < n; i++)
|
|
575
|
+
if (buf[i] === 0)
|
|
576
|
+
return true;
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Synthetic 3-way text merge via `git merge-file -p` (§Resolved-here-2). The
|
|
581
|
+
* three inputs are written to a private temp dir; stdout carries the merged
|
|
582
|
+
* result (with git-style conflict markers when overlapping), and the exit
|
|
583
|
+
* status is the conflict count (0 = clean; ≥128 / 255 / null = a real failure,
|
|
584
|
+
* which throws — never a silent best-effort).
|
|
585
|
+
*/
|
|
586
|
+
function mergeFile3(base, ours, theirs) {
|
|
587
|
+
const dir = mkdtempSync(join(tmpdir(), 'crtr-skill-sync-'));
|
|
588
|
+
try {
|
|
589
|
+
const oursPath = join(dir, 'ours');
|
|
590
|
+
const basePath = join(dir, 'base');
|
|
591
|
+
const theirsPath = join(dir, 'theirs');
|
|
592
|
+
writeFileSync(oursPath, ours);
|
|
593
|
+
writeFileSync(basePath, base);
|
|
594
|
+
writeFileSync(theirsPath, theirs);
|
|
595
|
+
const res = spawnSync('git', ['merge-file', '-p', '-L', 'crtr', '-L', 'base', '-L', 'claude', oursPath, basePath, theirsPath], { maxBuffer: 1 << 28 });
|
|
596
|
+
const status = res.status;
|
|
597
|
+
if (res.error || status === null || status < 0 || status >= 128) {
|
|
598
|
+
throw usage(`skill-sync: git merge-file failed (${res.error?.message ?? `status ${status}`})`);
|
|
599
|
+
}
|
|
600
|
+
return { merged: res.stdout, conflicts: status > 0 };
|
|
601
|
+
}
|
|
602
|
+
finally {
|
|
603
|
+
rmSync(dir, { recursive: true, force: true });
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
// ── Small utilities ──────────────────────────────────────────────────────────
|
|
607
|
+
function assetsEqual(a, b) {
|
|
608
|
+
if (a.size !== b.size)
|
|
609
|
+
return false;
|
|
610
|
+
for (const [rel, buf] of a) {
|
|
611
|
+
const other = b.get(rel);
|
|
612
|
+
if (!other || !other.equals(buf))
|
|
613
|
+
return false;
|
|
614
|
+
}
|
|
615
|
+
return true;
|
|
616
|
+
}
|
|
617
|
+
function unionKeys(...maps) {
|
|
618
|
+
const out = new Set();
|
|
619
|
+
for (const m of maps)
|
|
620
|
+
for (const k of m.keys())
|
|
621
|
+
out.add(k);
|
|
622
|
+
return out;
|
|
623
|
+
}
|
|
624
|
+
/** Assemble a human-readable conflict report (R-E6 / R-X1). */
|
|
625
|
+
function buildReport(id, entries) {
|
|
626
|
+
const lines = [`# skill-sync conflict: ${id}`, ''];
|
|
627
|
+
lines.push('This pair was NOT written. Resolve the conflicts below by editing the ' +
|
|
628
|
+
'endpoints, then re-run `crtr sys sync`.', '');
|
|
629
|
+
for (const e of entries) {
|
|
630
|
+
lines.push('---', '', e, '');
|
|
631
|
+
}
|
|
632
|
+
return lines.join('\n');
|
|
633
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-way export of crtr-generated host artifacts — the sole writer of
|
|
3
|
+
* generated content to `~/.claude/skills`, `~/.claude/commands`, and
|
|
4
|
+
* `~/.pi/agent/prompts`. Replaces the two host-dir writers that lived in
|
|
5
|
+
* bootstrap.ts (`ensureBootSkill` + `ensureSlashCommands`), collapsing their
|
|
6
|
+
* two clobber-guards into one `writeIfOurs` and their two kill-switches
|
|
7
|
+
* (`CRTR_NO_BOOT_SKILL` / `CRTR_NO_MODE_CMDS`) into one `CRTR_NO_EXPORTS`.
|
|
8
|
+
*
|
|
9
|
+
* Triggered per-run by cli.ts (argv-gated, exactly as the old writers were) so
|
|
10
|
+
* first-run provisioning is preserved. Best-effort: a provisioning fault must
|
|
11
|
+
* never break an unrelated `crtr` command (the deliberate, pre-existing
|
|
12
|
+
* behavior of the deleted writers).
|
|
13
|
+
*/
|
|
14
|
+
import type { RootDef } from '../command.js';
|
|
15
|
+
/** Write `content` to `path` unless a user-customized file is already there.
|
|
16
|
+
* Rolls forward our own (marker-bearing) versions; skips if identical. Creates
|
|
17
|
+
* only the leaf dir, never the host root. The single generalization of
|
|
18
|
+
* bootstrap.ts's two clobber-guards. */
|
|
19
|
+
export declare function writeIfOurs(path: string, content: string, markerPrefix: string): void;
|
|
20
|
+
/** Render and write every built-in export artifact to every present host.
|
|
21
|
+
* Best-effort: swallows errors (debug-logged) so a provisioning fault never
|
|
22
|
+
* breaks an unrelated `crtr` command. Kill switch: `CRTR_NO_EXPORTS=1`. */
|
|
23
|
+
export declare function provisionExports(root: RootDef, argv?: string[]): void;
|