@workerstack/repo 0.0.0-stage → 0.1.0
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/CHANGELOG.md +45 -0
- package/LICENSE +21 -0
- package/README.md +0 -3
- package/agents/global-settings/antigravity.json +3 -0
- package/agents/global-settings/claude.json +6 -0
- package/agents/global-settings/codex.toml +2 -0
- package/agents/global-settings/cursor.json +3 -0
- package/agents/global-settings/grok.toml +36 -0
- package/agents/global-settings/manifest.json +36 -0
- package/agents/grok/hooks/orchestrate-parent.json +60 -0
- package/agents/grok/hooks/rtk.json +16 -0
- package/agents/grok/hooks/rulesync.json +3 -0
- package/agents/grok/roles/scout.toml +5 -0
- package/agents/hooks.jsonc +67 -0
- package/agents/mcp-policy.jsonc +16 -0
- package/agents/rules/overview.md +21 -0
- package/agents/settings/claude.json +5 -0
- package/agents/skills/ask/SKILL.md +13 -0
- package/agents/skills/auto/SKILL.md +13 -0
- package/agents/skills/away/SKILL.md +19 -0
- package/agents/skills/blast-radius/SKILL.md +54 -0
- package/agents/skills/bro/SKILL.md +9 -0
- package/agents/skills/browser/SKILL.md +16 -0
- package/agents/skills/clasp/SKILL.md +11 -0
- package/agents/skills/commit/SKILL.md +35 -0
- package/agents/skills/commit-and-push/SKILL.md +9 -0
- package/agents/skills/commit-push/SKILL.md +59 -0
- package/agents/skills/commit-push-deploy/SKILL.md +74 -0
- package/agents/skills/commit-push-release/SKILL.md +75 -0
- package/agents/skills/correct/SKILL.md +39 -0
- package/agents/skills/do/SKILL.md +21 -0
- package/agents/skills/explain/SKILL.md +13 -0
- package/agents/skills/google-workspace/SKILL.md +12 -0
- package/agents/skills/handoff/SKILL.md +81 -0
- package/agents/skills/implement-plan/SKILL.md +29 -0
- package/agents/skills/implement-roadmap/SKILL.md +27 -0
- package/agents/skills/jev/SKILL.md +17 -0
- package/agents/skills/listen/SKILL.md +15 -0
- package/agents/skills/pickup/SKILL.md +58 -0
- package/agents/skills/principle-attack-the-premise/SKILL.md +24 -0
- package/agents/skills/principle-boundary-discipline/SKILL.md +38 -0
- package/agents/skills/principle-build-the-lever/SKILL.md +28 -0
- package/agents/skills/principle-encode-lessons-in-structure/SKILL.md +15 -0
- package/agents/skills/principle-exhaust-the-design-space/SKILL.md +12 -0
- package/agents/skills/principle-experience-first/SKILL.md +22 -0
- package/agents/skills/principle-explain-the-number/SKILL.md +21 -0
- package/agents/skills/principle-fix-root-causes/SKILL.md +24 -0
- package/agents/skills/principle-foundational-thinking/SKILL.md +25 -0
- package/agents/skills/principle-guard-the-context-window/SKILL.md +16 -0
- package/agents/skills/principle-laziness-protocol/SKILL.md +13 -0
- package/agents/skills/principle-make-operations-idempotent/SKILL.md +25 -0
- package/agents/skills/principle-migrate-callers-then-delete-legacy-apis/SKILL.md +18 -0
- package/agents/skills/principle-minimize-reader-load/SKILL.md +22 -0
- package/agents/skills/principle-model-the-domain/SKILL.md +26 -0
- package/agents/skills/principle-never-block-on-the-human/SKILL.md +17 -0
- package/agents/skills/principle-outcome-oriented-execution/SKILL.md +25 -0
- package/agents/skills/principle-prove-it-works/SKILL.md +25 -0
- package/agents/skills/principle-redesign-from-first-principles/SKILL.md +18 -0
- package/agents/skills/principle-separate-before-serializing-shared-state/SKILL.md +20 -0
- package/agents/skills/principle-sequence-verifiable-units/SKILL.md +22 -0
- package/agents/skills/principle-subtract-before-you-add/SKILL.md +21 -0
- package/agents/skills/principle-test-behavior-not-implementation/SKILL.md +24 -0
- package/agents/skills/principle-type-system-discipline/SKILL.md +17 -0
- package/agents/skills/review-plan/SKILL.md +28 -0
- package/agents/skills/review-roadmap/SKILL.md +28 -0
- package/agents/skills/show-me/SKILL.md +21 -0
- package/agents/skills/speak/SKILL.md +15 -0
- package/agents/skills/su/SKILL.md +11 -0
- package/agents/skills/teach/SKILL.md +25 -0
- package/agents/skills/tmp/SKILL.md +54 -0
- package/agents/skills/typescript-best-practices/SKILL.md +32 -0
- package/agents/skills/typescript-best-practices/references/patterns.md +328 -0
- package/agents/skills/unslop/SKILL.md +62 -0
- package/agents/skills/write-plan/SKILL.md +33 -0
- package/agents/skills/write-roadmap/SKILL.md +66 -0
- package/agents/subagents/implementer-deep.md +57 -0
- package/agents/subagents/implementer-fast.md +57 -0
- package/agents/subagents/orchestrator.md +43 -0
- package/agents/subagents/researcher.md +47 -0
- package/agents/subagents/scout.md +44 -0
- package/bin/repo.ts +4 -0
- package/cli/actionlint-default.test.ts +38 -0
- package/cli/actionlint-download.test.ts +162 -0
- package/cli/actionlint-real.test.ts +33 -0
- package/cli/actionlint.constants.ts +43 -0
- package/cli/actionlint.test.ts +108 -0
- package/cli/actionlint.ts +178 -0
- package/cli/bin.test.ts +54 -0
- package/cli/bin.ts +40 -0
- package/cli/check-changelog.test.ts +40 -0
- package/cli/check-changelog.ts +12 -0
- package/cli/check-comments.test.ts +157 -0
- package/cli/check-comments.ts +123 -0
- package/cli/check-env-modules.test.ts +85 -0
- package/cli/check-env-modules.ts +56 -0
- package/cli/check-magic.test.ts +139 -0
- package/cli/check-magic.ts +120 -0
- package/cli/check-manifests.constants.ts +4 -0
- package/cli/check-manifests.test.ts +210 -0
- package/cli/check-manifests.ts +71 -0
- package/cli/check-nightly.test.ts +103 -0
- package/cli/check-nightly.ts +37 -0
- package/cli/check-ratchets-missing.test.ts +46 -0
- package/cli/check-ratchets-mutation.test.ts +49 -0
- package/cli/check-ratchets.constants.ts +20 -0
- package/cli/check-ratchets.test.ts +190 -0
- package/cli/check-ratchets.ts +193 -0
- package/cli/check-rejections.test.ts +220 -0
- package/cli/check-rejections.ts +153 -0
- package/cli/check-reports.ts +37 -0
- package/cli/check-tsconfig.test.ts +507 -0
- package/cli/check-tsconfig.ts +205 -0
- package/cli/commands.test.ts +114 -0
- package/cli/commands.ts +218 -0
- package/cli/commit-changelog.ts +27 -0
- package/cli/commit-msg-changelog.test.ts +93 -0
- package/cli/commit-msg-range.test.ts +126 -0
- package/cli/commit-msg-red-proof.test.ts +147 -0
- package/cli/commit-msg-repo.test.ts +153 -0
- package/cli/commit-msg.test.ts +101 -0
- package/cli/commit-msg.ts +194 -0
- package/cli/commit-range.constants.ts +17 -0
- package/cli/commit-range.ts +57 -0
- package/cli/config-knip.test.ts +70 -0
- package/cli/config-nightly.test.ts +84 -0
- package/cli/config-tdd.test.ts +46 -0
- package/cli/config.test.ts +576 -0
- package/cli/config.ts +261 -0
- package/cli/conventional.ts +9 -0
- package/cli/coverage-config.ts +62 -0
- package/cli/coverage.constants.ts +6 -0
- package/cli/coverage.test.ts +199 -0
- package/cli/coverage.ts +127 -0
- package/cli/env.constants.ts +10 -0
- package/cli/env.test.ts +56 -0
- package/cli/env.ts +72 -0
- package/cli/fixtures/base-config.ts +13 -0
- package/cli/fixtures/gate-world.ts +48 -0
- package/cli/fixtures/knip-run.ts +38 -0
- package/cli/fixtures/mutate-fakes.ts +137 -0
- package/cli/fixtures/planted-repo.ts +50 -0
- package/cli/fixtures/publish-world.ts +71 -0
- package/cli/fixtures/red-proof-repo.ts +72 -0
- package/cli/fixtures/release-context.ts +15 -0
- package/cli/fixtures/release-world.ts +111 -0
- package/cli/fixtures/stub-bin.ts +40 -0
- package/cli/guard-diagnostics.test.ts +157 -0
- package/cli/guard-diagnostics.ts +195 -0
- package/cli/init.test.ts +487 -0
- package/cli/init.ts +275 -0
- package/cli/knip-check-ignores.test.ts +106 -0
- package/cli/knip-check-real.test.ts +65 -0
- package/cli/knip-check.constants.ts +46 -0
- package/cli/knip-check.test.ts +114 -0
- package/cli/knip-check.ts +169 -0
- package/cli/knip-schema.ts +67 -0
- package/cli/known-failures.constants.ts +22 -0
- package/cli/known-failures.test.ts +138 -0
- package/cli/known-failures.ts +131 -0
- package/cli/lint/as-needs-reason.ts +41 -0
- package/cli/lint/boundaries.ts +73 -0
- package/cli/lint/class-file.ts +39 -0
- package/cli/lint/config-file.ts +32 -0
- package/cli/lint/entry-file.ts +54 -0
- package/cli/lint/env-in-config.ts +66 -0
- package/cli/lint/filename-matches-export.ts +63 -0
- package/cli/lint/index-file.ts +22 -0
- package/cli/lint/index.ts +47 -0
- package/cli/lint/is-reason.ts +6 -0
- package/cli/lint/manifest.ts +46 -0
- package/cli/lint/names.ts +59 -0
- package/cli/lint/no-commented-out-code.ts +105 -0
- package/cli/lint/no-mock-of-subject.ts +64 -0
- package/cli/lint/no-module-side-effects.ts +94 -0
- package/cli/lint/no-module-state.ts +58 -0
- package/cli/lint/no-side-effect-import.ts +17 -0
- package/cli/lint/no-v8-ignore.ts +18 -0
- package/cli/lint/script-rules.ts +9 -0
- package/cli/lint/skip-on-platform.constants.ts +8 -0
- package/cli/lint/skip-on-platform.ts +118 -0
- package/cli/lint/todo-needs-id.ts +63 -0
- package/cli/lint/types-file.ts +28 -0
- package/cli/lint-schema.ts +32 -0
- package/cli/magic/fixtures/magic-repo.ts +64 -0
- package/cli/magic/magic-hit.ts +2 -0
- package/cli/magic/magic-nodes.ts +44 -0
- package/cli/magic/magic-numbers.test.ts +99 -0
- package/cli/magic/magic-numbers.ts +117 -0
- package/cli/magic/magic-strings.test.ts +38 -0
- package/cli/magic/magic-strings.ts +81 -0
- package/cli/main.test.ts +853 -0
- package/cli/main.ts +292 -0
- package/cli/mutate-args.ts +79 -0
- package/cli/mutate-check.test.ts +147 -0
- package/cli/mutate-deps.ts +42 -0
- package/cli/mutate-edges.test.ts +150 -0
- package/cli/mutate-files.test.ts +100 -0
- package/cli/mutate-files.ts +31 -0
- package/cli/mutate-floors.test.ts +91 -0
- package/cli/mutate-floors.ts +104 -0
- package/cli/mutate-real-deps.ts +57 -0
- package/cli/mutate-real.test.ts +96 -0
- package/cli/mutate-report.ts +96 -0
- package/cli/mutate-run.ts +128 -0
- package/cli/mutate-shards.ts +64 -0
- package/cli/mutate.constants.ts +67 -0
- package/cli/mutate.test.ts +182 -0
- package/cli/mutate.ts +64 -0
- package/cli/nested-vitest-flags.ts +8 -0
- package/cli/nightly-config.constants.ts +14 -0
- package/cli/nightly-config.ts +62 -0
- package/cli/prompt.test.ts +202 -0
- package/cli/prompt.ts +1 -0
- package/cli/red-proof-edges.test.ts +78 -0
- package/cli/red-proof-failures.test.ts +82 -0
- package/cli/red-proof-tree.test.ts +157 -0
- package/cli/red-proof-verdict.test.ts +44 -0
- package/cli/red-proof.constants.ts +14 -0
- package/cli/red-proof.ts +202 -0
- package/cli/release/changelog-parse.ts +83 -0
- package/cli/release/changelog.test.ts +141 -0
- package/cli/release/changelog.ts +106 -0
- package/cli/release/gate-refusals.test.ts +85 -0
- package/cli/release/gate.test.ts +63 -0
- package/cli/release/gate.ts +70 -0
- package/cli/release/git.ts +66 -0
- package/cli/release/github.test.ts +128 -0
- package/cli/release/github.ts +46 -0
- package/cli/release/list-dir.test.ts +11 -0
- package/cli/release/list-dir.ts +6 -0
- package/cli/release/main.test.ts +48 -0
- package/cli/release/main.ts +51 -0
- package/cli/release/manifest.test.ts +24 -0
- package/cli/release/manifest.ts +34 -0
- package/cli/release/pack.test.ts +107 -0
- package/cli/release/pack.ts +76 -0
- package/cli/release/plan.ts +127 -0
- package/cli/release/publish-run.test.ts +98 -0
- package/cli/release/publish.test.ts +127 -0
- package/cli/release/publish.ts +125 -0
- package/cli/release/refuse.ts +5 -0
- package/cli/release/release-messages.constants.ts +96 -0
- package/cli/release/release-refusals.test.ts +149 -0
- package/cli/release/release.constants.ts +128 -0
- package/cli/release/release.test.ts +199 -0
- package/cli/release/release.ts +145 -0
- package/cli/release/version.test.ts +118 -0
- package/cli/release/version.ts +69 -0
- package/cli/release/wait.test.ts +101 -0
- package/cli/release/wait.ts +95 -0
- package/cli/script-diagnostics.test.ts +76 -0
- package/cli/script-diagnostics.ts +62 -0
- package/cli/scripts/board.ts +519 -0
- package/cli/scripts/browser.ts +994 -0
- package/cli/scripts/change-set.ts +105 -0
- package/cli/scripts/check-globals.ts +199 -0
- package/cli/scripts/ci-skips.constants.ts +17 -0
- package/cli/scripts/ci-skips.ts +118 -0
- package/cli/scripts/ci-wait-run.ts +28 -0
- package/cli/scripts/ci-wait.constants.ts +44 -0
- package/cli/scripts/ci-wait.ts +175 -0
- package/cli/scripts/ci.constants.ts +15 -0
- package/cli/scripts/ci.ts +208 -0
- package/cli/scripts/clean.ts +363 -0
- package/cli/scripts/comments-step.ts +15 -0
- package/cli/scripts/doctor.ts +828 -0
- package/cli/scripts/emit-agent-shell-tools.ts +91 -0
- package/cli/scripts/emit-mcp-policy.ts +402 -0
- package/cli/scripts/folder-map.ts +353 -0
- package/cli/scripts/gate-fat-tools.ts +1095 -0
- package/cli/scripts/gitignore-guard.ts +338 -0
- package/cli/scripts/google-workspace.ts +779 -0
- package/cli/scripts/hook-command.ts +72 -0
- package/cli/scripts/hook-input.ts +178 -0
- package/cli/scripts/hook-io.constants.ts +5 -0
- package/cli/scripts/hook-io.ts +46 -0
- package/cli/scripts/hooks-union.ts +469 -0
- package/cli/scripts/interactive.ts +30 -0
- package/cli/scripts/jev.ts +420 -0
- package/cli/scripts/job.ts +934 -0
- package/cli/scripts/jsonc.ts +9 -0
- package/cli/scripts/lessons.ts +564 -0
- package/cli/scripts/lint-baseline.ts +233 -0
- package/cli/scripts/lint-main.ts +24 -0
- package/cli/scripts/lint-skips.ts +139 -0
- package/cli/scripts/lint.constants.ts +6 -0
- package/cli/scripts/lint.ts +142 -0
- package/cli/scripts/listen.ts +962 -0
- package/cli/scripts/managed-text.ts +47 -0
- package/cli/scripts/orchestrate-parent.ts +1155 -0
- package/cli/scripts/parse-need.ts +81 -0
- package/cli/scripts/persist-handoff.ts +228 -0
- package/cli/scripts/persist-implementation.ts +276 -0
- package/cli/scripts/persist-plan.ts +888 -0
- package/cli/scripts/persist-roadmap.ts +478 -0
- package/cli/scripts/persist-tmp.ts +177 -0
- package/cli/scripts/persist.ts +54 -0
- package/cli/scripts/phase-files.ts +121 -0
- package/cli/scripts/prompt.ts +38 -0
- package/cli/scripts/purge-global-mcps.ts +313 -0
- package/cli/scripts/rejections-step.ts +37 -0
- package/cli/scripts/rtk-grok.ts +102 -0
- package/cli/scripts/scrub-spawn-prompt.ts +103 -0
- package/cli/scripts/scrub-text-lib.ts +58 -0
- package/cli/scripts/session-map.ts +69 -0
- package/cli/scripts/setup.ts +862 -0
- package/cli/scripts/skill-conflicts.ts +82 -0
- package/cli/scripts/speak.ts +370 -0
- package/cli/scripts/strategy.ts +168 -0
- package/cli/scripts/sync-global-settings.ts +316 -0
- package/cli/scripts/turn-steps.ts +249 -0
- package/cli/scripts/turn.ts +157 -0
- package/cli/scripts/typecheck-step.ts +43 -0
- package/cli/scripts/vscode-tasks.ts +175 -0
- package/cli/scripts/workspace-root.ts +160 -0
- package/cli/scripts/worktree.ts +806 -0
- package/cli/spawn.test.ts +34 -0
- package/cli/spawn.ts +27 -0
- package/cli/staged-change.constants.ts +11 -0
- package/cli/staged-change.ts +79 -0
- package/cli/stryker-preload.test.ts +50 -0
- package/cli/stryker-preload.ts +9 -0
- package/cli/sync/agents/frontmatter.test.ts +197 -0
- package/cli/sync/agents/frontmatter.ts +140 -0
- package/cli/sync/agents/hooks.test.ts +517 -0
- package/cli/sync/agents/hooks.ts +401 -0
- package/cli/sync/agents/rules.test.ts +229 -0
- package/cli/sync/agents/rules.ts +84 -0
- package/cli/sync/agents/sources.test.ts +375 -0
- package/cli/sync/agents/sources.ts +244 -0
- package/cli/sync/agents/subagents.test.ts +149 -0
- package/cli/sync/agents/subagents.ts +158 -0
- package/cli/sync/agents-text.test.ts +31 -0
- package/cli/sync/agents-text.ts +36 -0
- package/cli/sync/fixtures/copied.README.md +35 -0
- package/cli/sync/fixtures/copied.tgz +0 -0
- package/cli/sync/fixtures/maps/golden/INDEX.md +22 -0
- package/cli/sync/fixtures/maps-ws.tgz +0 -0
- package/cli/sync/git-files.test.ts +340 -0
- package/cli/sync/git-files.ts +197 -0
- package/cli/sync/git-hooks.test.ts +94 -0
- package/cli/sync/golden.test.ts +15 -0
- package/cli/sync/hooks-mode.test.ts +99 -0
- package/cli/sync/hosts.ts +28 -0
- package/cli/sync/knip-config.constants.ts +4 -0
- package/cli/sync/knip-config.test.ts +147 -0
- package/cli/sync/knip-config.ts +108 -0
- package/cli/sync/manifest.test.ts +78 -0
- package/cli/sync/manifest.ts +60 -0
- package/cli/sync/maps.test.ts +304 -0
- package/cli/sync/maps.ts +297 -0
- package/cli/sync/migrate.test.ts +2000 -0
- package/cli/sync/migrate.ts +1104 -0
- package/cli/sync/nightly-jobs.test.ts +63 -0
- package/cli/sync/nightly.constants.ts +48 -0
- package/cli/sync/nightly.test.ts +100 -0
- package/cli/sync/nightly.ts +75 -0
- package/cli/sync/outputs.test.ts +351 -0
- package/cli/sync/outputs.ts +168 -0
- package/cli/sync/oxlint-config.test.ts +178 -0
- package/cli/sync/oxlint-config.ts +168 -0
- package/cli/sync/release-jobs.test.ts +29 -0
- package/cli/sync/release-reports.test.ts +31 -0
- package/cli/sync/release-workflow.constants.ts +61 -0
- package/cli/sync/release-workflow.test.ts +192 -0
- package/cli/sync/release-workflow.ts +86 -0
- package/cli/sync/release.constants.ts +93 -0
- package/cli/sync/release.test.ts +187 -0
- package/cli/sync/release.ts +112 -0
- package/cli/sync/repo-dir.test.ts +371 -0
- package/cli/sync/repo-dir.ts +205 -0
- package/cli/sync/standards.constants.ts +8 -0
- package/cli/sync/standards.test.ts +206 -0
- package/cli/sync/standards.ts +179 -0
- package/cli/sync/stock-history.json +2796 -0
- package/cli/sync/stock-history.test.ts +66 -0
- package/cli/sync/stock-history.ts +84 -0
- package/cli/sync/stock.test.ts +47 -0
- package/cli/sync/stock.ts +24 -0
- package/cli/sync/trees.test.ts +621 -0
- package/cli/sync/trees.ts +280 -0
- package/cli/sync/validate.test.ts +299 -0
- package/cli/sync/validate.ts +402 -0
- package/cli/sync/workspace.test.ts +180 -0
- package/cli/sync/workspace.ts +138 -0
- package/cli/sync/write.test.ts +341 -0
- package/cli/sync/write.ts +231 -0
- package/cli/sync.test.ts +1495 -0
- package/cli/sync.ts +360 -0
- package/cli/tdd-scope.test.ts +36 -0
- package/cli/tdd-scope.ts +41 -0
- package/cli/test-files.ts +16 -0
- package/cli/ts-program.ts +69 -0
- package/cli/tsconfig.constants.ts +8 -0
- package/cli/workerstack-config.types.ts +21 -0
- package/dist/cli/env.d.ts +10 -0
- package/dist/cli/env.js +64 -0
- package/dist/presets/temp-root-env.d.ts +10 -0
- package/dist/presets/temp-root-env.js +20 -0
- package/dist/presets/temp-root.constants.d.ts +6 -0
- package/dist/presets/temp-root.constants.js +6 -0
- package/dist/presets/temp-root.d.ts +2 -0
- package/dist/presets/temp-root.js +60 -0
- package/dist/presets/vitest.constants.d.ts +16 -0
- package/dist/presets/vitest.constants.js +16 -0
- package/dist/presets/vitest.d.ts +11 -0
- package/dist/presets/vitest.js +67 -0
- package/dist/presets/workers-plugin.d.ts +10 -0
- package/dist/presets/workers-plugin.js +54 -0
- package/package.json +141 -4
- package/references/INDEX.md +100 -0
- package/references/browser.md +66 -0
- package/references/cascade.md +15 -0
- package/references/clasp.md +21 -0
- package/references/cloudflare.md +36 -0
- package/references/google-workspace.md +79 -0
- package/references/hooks.md +103 -0
- package/references/implementation-format.md +119 -0
- package/references/jev.md +56 -0
- package/references/lessons.md +73 -0
- package/references/listen.md +80 -0
- package/references/map.md +107 -0
- package/references/output.md +47 -0
- package/references/plan-format.md +264 -0
- package/references/review-loop.md +48 -0
- package/references/roadmap-format.md +104 -0
- package/references/rtk.md +27 -0
- package/references/scope.md +76 -0
- package/references/setup.md +42 -0
- package/references/speak.md +59 -0
- package/tsconfig/base.json +28 -0
- package/tsconfig/bun.d.ts +1 -0
- package/tsconfig/bun.json +8 -0
- package/tsconfig/reset.d.ts +1 -0
- package/tsconfig/strict.json +12 -0
- package/tsconfig/web.json +7 -0
- package/tsconfig/worker.d.ts +1 -0
- package/tsconfig/worker.json +8 -0
|
@@ -0,0 +1,1104 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { isDeepStrictEqual } from "node:util";
|
|
5
|
+
import { extraH2Sections, extractUserMarkdown } from "./agents-text.ts";
|
|
6
|
+
import { emitYaml, parseDoc } from "./agents/frontmatter.ts";
|
|
7
|
+
import { stripJsonc } from "../scripts/jsonc.ts";
|
|
8
|
+
import { CONFIG_FILE, SKILL_PACKS, loadConfig, type ResolvedConfig, type SkillPack } from "../config.ts";
|
|
9
|
+
import { spawnSync } from "../spawn.ts";
|
|
10
|
+
import { untrackedEntries } from "./git-files.ts";
|
|
11
|
+
import { isHostOutput } from "./hosts.ts";
|
|
12
|
+
import { normalizeRel } from "./manifest.ts";
|
|
13
|
+
import type { Output } from "./outputs.ts";
|
|
14
|
+
import { PACKAGE_NAME } from "./stock.ts";
|
|
15
|
+
import { HOOK_COMMANDS_KEY, hashText, hookCommands, readStockHistory, type StockHistory } from "./stock-history.ts";
|
|
16
|
+
|
|
17
|
+
/** The file only a copied install has; it goes last, so a crash always leaves the next run what it needs to resume. */
|
|
18
|
+
export const COPIED_MARKER = ".rulesync/scripts/workspace-root.ts";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One thing the migration does. Paths are root-relative with `/`. A step is a no-op once done and is planned only
|
|
22
|
+
* while its source is still there, so a run that stopped anywhere is finished by planning again.
|
|
23
|
+
*/
|
|
24
|
+
export type MigrationStep =
|
|
25
|
+
/**
|
|
26
|
+
* Moves a file or dir; `except` names direct children that stay. `note` is the line printed. A `mark` is written
|
|
27
|
+
* to its source path first, so the moved file says `overrides:` and a run that stopped between the two is finished
|
|
28
|
+
* by planning again.
|
|
29
|
+
*/
|
|
30
|
+
| {
|
|
31
|
+
kind: "move";
|
|
32
|
+
from: string;
|
|
33
|
+
to: string;
|
|
34
|
+
except?: readonly string[];
|
|
35
|
+
note: string;
|
|
36
|
+
mark?: { path: string; content: string; note: string };
|
|
37
|
+
}
|
|
38
|
+
/** Writes a file. `note` is the line printed. */
|
|
39
|
+
| { kind: "write"; path: string; content: string; note: string }
|
|
40
|
+
/** Prints a line for something the user must do by hand. */
|
|
41
|
+
| { kind: "note"; note: string }
|
|
42
|
+
/**
|
|
43
|
+
* A destination that already holds other text, or (with `line`) a file of the user's that cannot move; any of
|
|
44
|
+
* these stops the run before it changes anything.
|
|
45
|
+
*/
|
|
46
|
+
| { kind: "conflict"; from: string; to: string; line?: string }
|
|
47
|
+
/** `git rm -r --cached` of generated paths that are tracked. */
|
|
48
|
+
| { kind: "untrack"; paths: string[] }
|
|
49
|
+
/** Removes stock copies, then the copied `.rulesync/` with the marker last. */
|
|
50
|
+
| { kind: "remove"; paths: string[] };
|
|
51
|
+
|
|
52
|
+
/** Seams for tests: which of `paths` git tracks (null when there is no git), and a crash after step `index`. */
|
|
53
|
+
export type MigrateDeps = {
|
|
54
|
+
tracked?: (root: string, paths: string[]) => string[] | null;
|
|
55
|
+
/** The hashes of past stock files; the shipped table when omitted. */
|
|
56
|
+
history?: StockHistory;
|
|
57
|
+
afterStep?: (index: number) => void;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const PREFIX = "repo sync: ";
|
|
61
|
+
const COPIED_DIR = ".rulesync";
|
|
62
|
+
const REPO_DIR = ".repo";
|
|
63
|
+
const REFERENCES_DIR = "references";
|
|
64
|
+
const CLAUDE_SETTINGS = `${COPIED_DIR}/settings/claude.json`;
|
|
65
|
+
const REPLACES = " (it now replaces the stock one)";
|
|
66
|
+
/** Files older inits generated into `.rulesync/reference`; never the consumer's. */
|
|
67
|
+
const OLD_GENERATED_REFS = new Set(["law.md", "INDEX.md", "skills-map.md", "refs-map.md"]);
|
|
68
|
+
/** Entries of the copied dir with a rule of their own; any other entry stops the run. */
|
|
69
|
+
const HANDLED = new Set([
|
|
70
|
+
"rules",
|
|
71
|
+
"subagents",
|
|
72
|
+
"lessons",
|
|
73
|
+
"hooks.jsonc",
|
|
74
|
+
"mcp-policy.jsonc",
|
|
75
|
+
"permissions.json",
|
|
76
|
+
"global-settings",
|
|
77
|
+
"reference",
|
|
78
|
+
"repo.features.jsonc",
|
|
79
|
+
"repo.pipeline.jsonc",
|
|
80
|
+
"scripts",
|
|
81
|
+
"settings",
|
|
82
|
+
]);
|
|
83
|
+
const RULE_FRONTMATTER = "---\ndescription: Carried over from your earlier setup\n---\n";
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The steps that move a copied install's own content to `.repo/` and `references/`, untrack its generated paths and
|
|
87
|
+
* remove the copy. Empty without the copied-install marker, or while the stock package is not installed. `generated`
|
|
88
|
+
* is the text sync plans for each generated file (see `generatedFiles`): the package ships no generated trees, so
|
|
89
|
+
* it is the stock version of those files. `ignored` is the git ignore entries of the untracked outputs (see
|
|
90
|
+
* `untrackedEntries`), whose tracked files are untracked.
|
|
91
|
+
*/
|
|
92
|
+
export function planMigration(
|
|
93
|
+
root: string,
|
|
94
|
+
stock: string,
|
|
95
|
+
generated: ReadonlyMap<string, string>,
|
|
96
|
+
ignored: readonly string[],
|
|
97
|
+
deps: MigrateDeps = {},
|
|
98
|
+
): MigrationStep[] {
|
|
99
|
+
if (!present(path.join(root, COPIED_MARKER)) || !present(path.join(stock, "package.json"))) return [];
|
|
100
|
+
const plan = new Plan(root, stock, generated, deps.history ?? readStockHistory());
|
|
101
|
+
plan.rules();
|
|
102
|
+
plan.subagents();
|
|
103
|
+
plan.skills();
|
|
104
|
+
plan.references();
|
|
105
|
+
plan.agentsText();
|
|
106
|
+
plan.claudeSettings();
|
|
107
|
+
plan.lessons();
|
|
108
|
+
plan.overrides();
|
|
109
|
+
plan.copies();
|
|
110
|
+
plan.leftovers();
|
|
111
|
+
plan.packs();
|
|
112
|
+
plan.untrack(ignored, deps.tracked ?? gitTracked);
|
|
113
|
+
plan.remove();
|
|
114
|
+
return plan.steps;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The steps that keep what a consumer already has where sync is about to generate. `outputs` are what the generators
|
|
119
|
+
* plan for the current overlay and `manifest` the paths the last sync wrote: a path in the manifest is ours, and a
|
|
120
|
+
* file equal to its output or to a stock version holds nothing of the user's. Text that can move goes to `.repo/`
|
|
121
|
+
* (the AGENTS.md text, the settings keys and hooks, the MCP servers, a real skill dir); a tracked file under a
|
|
122
|
+
* host dir, or a host file sync would overwrite, that cannot move stops the run with one line per file.
|
|
123
|
+
*/
|
|
124
|
+
export function planAdoption(
|
|
125
|
+
root: string,
|
|
126
|
+
stock: string,
|
|
127
|
+
config: ResolvedConfig,
|
|
128
|
+
outputs: readonly Output[],
|
|
129
|
+
manifest: readonly string[],
|
|
130
|
+
deps: MigrateDeps = {},
|
|
131
|
+
): MigrationStep[] {
|
|
132
|
+
const plan = new Plan(root, stock, generatedFiles(outputs), deps.history ?? readStockHistory());
|
|
133
|
+
plan.adopt(config, outputs, new Set(manifest), deps.tracked ?? gitTracked);
|
|
134
|
+
return plan.steps;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The text of each file among `outputs`, by path. */
|
|
138
|
+
export function generatedFiles(outputs: readonly Output[]): Map<string, string> {
|
|
139
|
+
return new Map(outputs.flatMap((output) => (output.kind === "file" ? [[output.path, output.content] as const] : [])));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The host skill dirs an old install could hold real copies of stock skills in. */
|
|
143
|
+
const HOST_SKILL_DIRS = [".agents/skills/", ".claude/skills/"];
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Where the stock package keeps the file a copied install has at `rel`: its copied `.rulesync/scripts` are
|
|
147
|
+
* `cli/scripts`, the rest of `.rulesync/` is under `agents/`, and a skill in either host skill dir is in
|
|
148
|
+
* `agents/skills`. Other paths are the same.
|
|
149
|
+
*/
|
|
150
|
+
function stockRel(rel: string): string {
|
|
151
|
+
if (rel.startsWith(`${COPIED_DIR}/scripts/`))
|
|
152
|
+
return `cli/scripts/${rel.slice(COPIED_DIR.length + "/scripts/".length)}`;
|
|
153
|
+
if (rel.startsWith(`${COPIED_DIR}/`)) return `agents/${rel.slice(COPIED_DIR.length + 1)}`;
|
|
154
|
+
const skills = HOST_SKILL_DIRS.find((dir) => rel.startsWith(dir));
|
|
155
|
+
return skills === undefined ? rel : `agents/skills/${rel.slice(skills.length)}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The `repo sync:` lines for every conflict among `steps`, one each; empty when there is none. */
|
|
159
|
+
export function conflictLines(steps: readonly MigrationStep[]): string[] {
|
|
160
|
+
return steps.flatMap((step) =>
|
|
161
|
+
step.kind === "conflict"
|
|
162
|
+
? [
|
|
163
|
+
step.line ??
|
|
164
|
+
`${PREFIX}cannot move ${step.from}: ${step.to} already exists and differs; merge them by hand, then run \`bun repo sync\``,
|
|
165
|
+
]
|
|
166
|
+
: [],
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The paths git tracks, as root-relative `/` spellings: backslashes, a drive prefix and `./` all read the same. */
|
|
171
|
+
export function trackedRels(raw: readonly string[], root: string): string[] {
|
|
172
|
+
return raw.flatMap((entry) => normalizeRel(entry, root) ?? []);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Runs `steps` in order and returns the `repo sync:` lines. With a conflict among them it changes nothing and
|
|
177
|
+
* returns the conflict lines. A failure throws; the marker is still there, so the next run plans what is left.
|
|
178
|
+
*/
|
|
179
|
+
export async function applyMigration(
|
|
180
|
+
root: string,
|
|
181
|
+
steps: readonly MigrationStep[],
|
|
182
|
+
deps: MigrateDeps = {},
|
|
183
|
+
): Promise<string[]> {
|
|
184
|
+
const conflicts = conflictLines(steps);
|
|
185
|
+
if (conflicts.length > 0) return conflicts;
|
|
186
|
+
const lines: string[] = [];
|
|
187
|
+
let untracked: string[] = [];
|
|
188
|
+
let removed = false;
|
|
189
|
+
// No conflict survives the early return, so the loop sees only the steps that act.
|
|
190
|
+
const acting = steps.filter((step): step is Exclude<MigrationStep, { kind: "conflict" }> => step.kind !== "conflict");
|
|
191
|
+
for (const [index, step] of acting.entries()) {
|
|
192
|
+
if (step.kind === "move") {
|
|
193
|
+
if (step.mark !== undefined) writeAtomic(path.join(root, step.mark.path), step.mark.content);
|
|
194
|
+
moveTree(path.join(root, step.from), path.join(root, step.to), step.except ?? []);
|
|
195
|
+
lines.push(`${PREFIX}${step.note}`);
|
|
196
|
+
if (step.mark !== undefined) lines.push(`${PREFIX}${step.mark.note}`);
|
|
197
|
+
} else if (step.kind === "write") {
|
|
198
|
+
lines.push(...(await writeStep(root, step)).split("\n").map((line) => `${PREFIX}${line}`));
|
|
199
|
+
} else if (step.kind === "note") {
|
|
200
|
+
lines.push(`${PREFIX}${step.note}`);
|
|
201
|
+
} else if (step.kind === "untrack") {
|
|
202
|
+
untrack(root, step.paths);
|
|
203
|
+
untracked = step.paths;
|
|
204
|
+
} else {
|
|
205
|
+
removeAll(root, step.paths);
|
|
206
|
+
removed = true;
|
|
207
|
+
}
|
|
208
|
+
deps.afterStep?.(index);
|
|
209
|
+
}
|
|
210
|
+
const parts = [
|
|
211
|
+
...(untracked.length > 0 ? [`untracked ${untracked.join(", ")} (now generated)`] : []),
|
|
212
|
+
...(removed ? [`removed the copied ${COPIED_DIR}/`] : []),
|
|
213
|
+
];
|
|
214
|
+
if (parts.length > 0)
|
|
215
|
+
lines.push(`${PREFIX}${parts.join(" and ")}${untracked.length > 0 ? "; commit the result" : ""}`);
|
|
216
|
+
return lines;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
class Plan {
|
|
220
|
+
readonly steps: MigrationStep[] = [];
|
|
221
|
+
private readonly drop: string[] = [];
|
|
222
|
+
private readonly root: string;
|
|
223
|
+
private readonly stock: string;
|
|
224
|
+
private readonly generated: ReadonlyMap<string, string>;
|
|
225
|
+
private readonly history: StockHistory;
|
|
226
|
+
|
|
227
|
+
constructor(root: string, stock: string, generated: ReadonlyMap<string, string>, history: StockHistory) {
|
|
228
|
+
this.root = root;
|
|
229
|
+
this.stock = stock;
|
|
230
|
+
this.generated = generated;
|
|
231
|
+
this.history = history;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Rules the stock lacks move over; a stock rule's own `##` sections go to a `-local` sibling, so stock stays live. */
|
|
235
|
+
rules(): void {
|
|
236
|
+
for (const name of this.names(`${COPIED_DIR}/rules`)) {
|
|
237
|
+
const from = `${COPIED_DIR}/rules/${name}`;
|
|
238
|
+
const stockText = this.stockText(from);
|
|
239
|
+
const own = isDir(path.join(this.root, from)) ? null : this.text(from);
|
|
240
|
+
if (own !== null && this.unedited(from, own, stockText)) continue;
|
|
241
|
+
const to = `${REPO_DIR}/rules/${name}`;
|
|
242
|
+
if (own === null || stockText === null) {
|
|
243
|
+
this.move(from, to, `moved ${from} to ${to}`);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const extras = extraH2Sections(own, stockText);
|
|
247
|
+
if (extras === "") {
|
|
248
|
+
this.move(
|
|
249
|
+
from,
|
|
250
|
+
to,
|
|
251
|
+
`moved your edited ${from} to ${to} (it now replaces the stock rule)`,
|
|
252
|
+
[],
|
|
253
|
+
this.mark(from, to, name.replace(/\.md$/, ""), "rule"),
|
|
254
|
+
);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const heading = h1Of(stockText);
|
|
258
|
+
const local = `${REPO_DIR}/rules/${name.replace(/\.md$/, "")}-local.md`;
|
|
259
|
+
const content = `${RULE_FRONTMATTER}${heading === null ? "" : `${heading}\n\n`}${extras}\n`;
|
|
260
|
+
this.write(from, local, content, `moved your sections ${titlesOf(extras)} of ${from} to ${local}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Subagents the stock lacks move over; an edited stock subagent moves whole and overrides stock. */
|
|
265
|
+
subagents(): void {
|
|
266
|
+
for (const name of this.names(`${COPIED_DIR}/subagents`)) {
|
|
267
|
+
const from = `${COPIED_DIR}/subagents/${name}`;
|
|
268
|
+
const stockText = this.stockText(from);
|
|
269
|
+
const isFolder = isDir(path.join(this.root, from));
|
|
270
|
+
if (!isFolder && this.unedited(from, this.text(from), stockText)) continue;
|
|
271
|
+
const to = `${REPO_DIR}/subagents/${name}`;
|
|
272
|
+
if (stockText === null || isFolder) this.move(from, to, `moved ${from} to ${to}`);
|
|
273
|
+
else {
|
|
274
|
+
const mark = this.mark(from, to, name.replace(/\.md$/, ""), "subagent");
|
|
275
|
+
this.move(from, to, `moved your edited ${from} to ${to} (it now replaces the stock subagent)`, [], mark);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Skill dirs the stock lacks or the consumer edited move to `.repo/skills`; the rest are stock copies to remove. */
|
|
281
|
+
skills(): void {
|
|
282
|
+
for (const name of this.names(".agents/skills")) {
|
|
283
|
+
const from = `.agents/skills/${name}`;
|
|
284
|
+
if (!isDir(path.join(this.root, from))) continue;
|
|
285
|
+
const to = `${REPO_DIR}/skills/${name}`;
|
|
286
|
+
if (!this.editedSkill(from)) this.drop.push(from);
|
|
287
|
+
else if (!present(path.join(this.stock, stockRel(from)))) this.move(from, to, `moved ${from} to ${to}`);
|
|
288
|
+
else
|
|
289
|
+
this.move(
|
|
290
|
+
from,
|
|
291
|
+
to,
|
|
292
|
+
`moved your edited ${from} to ${to} (it now replaces the stock skill)`,
|
|
293
|
+
[],
|
|
294
|
+
this.mark(`${from}/SKILL.md`, `${to}/SKILL.md`, name, "skill"),
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Whether a skill dir holds a file that is no stock version, so it is the consumer's. */
|
|
300
|
+
private editedSkill(from: string): boolean {
|
|
301
|
+
return filesUnder(path.join(this.root, from)).some((file) => {
|
|
302
|
+
const rel = `${from}/${file}`;
|
|
303
|
+
return !this.unedited(rel, this.text(rel), this.stockText(rel));
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** `.rulesync/reference` follows the old init's rules; a consumer file goes to `references/` and extra sections too. */
|
|
308
|
+
references(): void {
|
|
309
|
+
const dir = `${COPIED_DIR}/reference`;
|
|
310
|
+
const names = this.names(dir).filter((name) => !OLD_GENERATED_REFS.has(name));
|
|
311
|
+
const stockNamed = (name: string) => name.endsWith(".md") && present(path.join(this.stock, REFERENCES_DIR, name));
|
|
312
|
+
for (const name of [...names.filter(stockNamed), ...names.filter((n) => !stockNamed(n))]) {
|
|
313
|
+
const from = `${dir}/${name}`;
|
|
314
|
+
const to = `${REFERENCES_DIR}/${name}`;
|
|
315
|
+
const stockText = this.stockText(to);
|
|
316
|
+
// a file is a past or current stock copy under either of its names: where older inits put it, or where it lives now
|
|
317
|
+
if (!isDir(path.join(this.root, from)) && this.unedited(from, this.text(from), stockText, [to])) continue;
|
|
318
|
+
if (!stockNamed(name)) {
|
|
319
|
+
this.move(from, to, `moved ${from} to ${to}`);
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
const extras = extraH2Sections(this.text(from), stockText!);
|
|
323
|
+
if (extras === "") {
|
|
324
|
+
this.move(from, to, `moved your edited ${from} to ${to}`);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const existing = this.read(to);
|
|
328
|
+
const missing = existing === null ? extras : extraH2Sections(extras, existing);
|
|
329
|
+
if (missing === "") continue;
|
|
330
|
+
const content =
|
|
331
|
+
existing === null
|
|
332
|
+
? `${h1Of(stockText!) ?? h1Of(this.text(from)) ?? `# ${name}`}\n\n${missing}\n`
|
|
333
|
+
: `${existing.trimEnd()}\n\n${missing}\n`;
|
|
334
|
+
this.steps.push({
|
|
335
|
+
kind: "write",
|
|
336
|
+
path: to,
|
|
337
|
+
content,
|
|
338
|
+
note: `moved your sections ${titlesOf(missing)} of ${from} to ${to}`,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** AGENTS.md text outside the guarded block becomes the `agents` rule, which every host reads. */
|
|
344
|
+
agentsText(): void {
|
|
345
|
+
const text = this.read("AGENTS.md");
|
|
346
|
+
const own =
|
|
347
|
+
text === null || this.unedited("AGENTS.md", text, this.stockText("AGENTS.md"))
|
|
348
|
+
? ""
|
|
349
|
+
: extractUserMarkdown(text).trim();
|
|
350
|
+
if (own === "") return;
|
|
351
|
+
const to = `${REPO_DIR}/rules/agents.md`;
|
|
352
|
+
this.write("AGENTS.md", to, `${RULE_FRONTMATTER}${own}\n`, `moved your AGENTS.md text to ${to}`);
|
|
353
|
+
// The text now lives in the rule; the stale file must not be there to be adopted again.
|
|
354
|
+
this.drop.push("AGENTS.md");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** `.claude/settings.json` keys the stock's own settings lack go to the overlay; `hooks` come from hooks.jsonc. */
|
|
358
|
+
claudeSettings(): void {
|
|
359
|
+
const rel = ".claude/settings.json";
|
|
360
|
+
const raw = this.read(rel);
|
|
361
|
+
if (raw !== null && this.unedited(rel, raw, this.stockText(rel))) return;
|
|
362
|
+
const have = readJson(path.join(this.root, rel));
|
|
363
|
+
if (have === null) {
|
|
364
|
+
this.steps.push({
|
|
365
|
+
kind: "note",
|
|
366
|
+
note: `${rel} is not valid JSON; copy any keys of yours into ${REPO_DIR}/settings/claude.json`,
|
|
367
|
+
});
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const base = parseJson(this.stockText(rel));
|
|
371
|
+
if (!isObject(have) || !isObject(base)) return;
|
|
372
|
+
const { hooks, ...mine } = have;
|
|
373
|
+
const paths: string[] = [];
|
|
374
|
+
const extras = settingsExtras(mine, base, "", paths);
|
|
375
|
+
const stockCommands = new Set(hookCommands(base));
|
|
376
|
+
const known = (command: string) =>
|
|
377
|
+
stockCommands.has(command) || this.history[HOOK_COMMANDS_KEY]?.includes(hashText(command)) === true;
|
|
378
|
+
const own = ownHooks(hooks, known);
|
|
379
|
+
if (extras === undefined && own.events.length === 0) return;
|
|
380
|
+
const to = `${REPO_DIR}/settings/claude.json`;
|
|
381
|
+
const content = { ...(extras as object | undefined), ...(own.events.length > 0 ? { hooks: own.hooks } : {}) };
|
|
382
|
+
const lines = [
|
|
383
|
+
...(paths.length > 0 ? [`moved your ${rel} keys ${paths.join(", ")} to ${to}`] : []),
|
|
384
|
+
...own.events.map(([event, count]) => `moved your ${rel} hooks ${event} (${count}) to ${to}`),
|
|
385
|
+
];
|
|
386
|
+
this.write(rel, to, `${JSON.stringify(content, null, 2)}\n`, lines.join("\n"));
|
|
387
|
+
this.drop.push(rel);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* What the consumer already keeps where sync generates, for a repo that never ran the copying init: see
|
|
392
|
+
* `planAdoption`. Nothing it cannot move is left to be overwritten or hidden by the gitignore block.
|
|
393
|
+
*/
|
|
394
|
+
adopt(
|
|
395
|
+
config: ResolvedConfig,
|
|
396
|
+
outputs: readonly Output[],
|
|
397
|
+
owned: ReadonlySet<string>,
|
|
398
|
+
tracked: (root: string, paths: string[]) => string[] | null,
|
|
399
|
+
): void {
|
|
400
|
+
const { hosts } = config.agents;
|
|
401
|
+
const ignored = untrackedEntries(hosts, outputs);
|
|
402
|
+
const stops = new Set<string>();
|
|
403
|
+
const stop = (rel: string) => stops.add(rel);
|
|
404
|
+
const byPath = new Map(outputs.map((output) => [output.path, output]));
|
|
405
|
+
const known = (rel: string, text: string) => this.unedited(rel, text, this.stockText(rel));
|
|
406
|
+
|
|
407
|
+
const skillDirs: string[] = [];
|
|
408
|
+
for (const dir of [".agents/skills", ".claude/skills"]) {
|
|
409
|
+
if (!isHostOutput(hosts, `${dir}/x`)) continue;
|
|
410
|
+
for (const name of this.names(dir)) {
|
|
411
|
+
const from = `${dir}/${name}`;
|
|
412
|
+
if (owned.has(from) || !isRealDir(path.join(this.root, from))) continue;
|
|
413
|
+
skillDirs.push(from);
|
|
414
|
+
if (!this.editedSkill(from)) continue;
|
|
415
|
+
const to = `${REPO_DIR}/skills/${name}`;
|
|
416
|
+
const stocked = present(path.join(this.stock, stockRel(from)));
|
|
417
|
+
this.move(
|
|
418
|
+
from,
|
|
419
|
+
to,
|
|
420
|
+
`moved ${from} to ${to}`,
|
|
421
|
+
[],
|
|
422
|
+
stocked ? this.mark(`${from}/SKILL.md`, `${to}/SKILL.md`, name, "skill") : undefined,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const inSkillDir = (rel: string) => skillDirs.some((dir) => rel.startsWith(`${dir}/`));
|
|
427
|
+
|
|
428
|
+
const adoptable = new Set(["AGENTS.md", ".mcp.json", ".claude/settings.json"]);
|
|
429
|
+
for (const output of outputs) {
|
|
430
|
+
if (owned.has(output.path) || !isHostOutput(hosts, output.path)) continue;
|
|
431
|
+
const abs = path.join(this.root, output.path);
|
|
432
|
+
const found = fs.lstatSync(abs, { throwIfNoEntry: false });
|
|
433
|
+
if (found === undefined || found.isSymbolicLink() || skillDirs.includes(output.path)) continue;
|
|
434
|
+
if (output.kind === "link" || found.isDirectory()) {
|
|
435
|
+
stop(output.path);
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
const text = this.text(output.path);
|
|
439
|
+
if (same(text, output.content) || known(output.path, text)) continue;
|
|
440
|
+
if (!adoptable.has(output.path)) stop(output.path);
|
|
441
|
+
else if (output.path === "AGENTS.md") this.adoptAgents(text);
|
|
442
|
+
else this.adoptJson(output.path, text, stop);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const rels = trackedRels(tracked(this.root, [...ignored]) ?? [], this.root);
|
|
446
|
+
for (const rel of rels) {
|
|
447
|
+
if (owned.has(rel) || byPath.has(rel) || adoptable.has(rel) || inSkillDir(rel) || !isHostOutput(hosts, rel))
|
|
448
|
+
continue;
|
|
449
|
+
const found = fs.lstatSync(path.join(this.root, rel), { throwIfNoEntry: false });
|
|
450
|
+
if (found === undefined || found.isSymbolicLink() || known(rel, this.text(rel))) continue;
|
|
451
|
+
stop(rel);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
for (const rel of [...stops].sort()) {
|
|
455
|
+
this.steps.push({
|
|
456
|
+
kind: "conflict",
|
|
457
|
+
from: rel,
|
|
458
|
+
to: "",
|
|
459
|
+
line: `${PREFIX}${rel} is yours and would be ignored; move it under ${REPO_DIR}/ (see node_modules/${PACKAGE_NAME}/${REFERENCES_DIR}/map.md) or delete it, then run \`bun repo sync\``,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
if (stops.size === 0 && this.steps.length > 0) this.untrack(ignored, tracked);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** The whole text of a hand-written AGENTS.md becomes the `agents` rule, which every host reads. */
|
|
466
|
+
private adoptAgents(text: string): void {
|
|
467
|
+
const own = extractUserMarkdown(text).trim();
|
|
468
|
+
if (own === "") return;
|
|
469
|
+
const to = `${REPO_DIR}/rules/agents.md`;
|
|
470
|
+
this.write("AGENTS.md", to, `${RULE_FRONTMATTER}${own}\n`, `adopted your AGENTS.md as ${to}`);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Moves the keys of a hand-written settings or MCP file that sync does not generate; a file that is no JSON object stops the run. */
|
|
474
|
+
private adoptJson(rel: string, text: string, stop: (rel: string) => void): void {
|
|
475
|
+
let have: unknown;
|
|
476
|
+
try {
|
|
477
|
+
have = JSON.parse(text);
|
|
478
|
+
} catch {
|
|
479
|
+
have = null;
|
|
480
|
+
}
|
|
481
|
+
if (!isObject(have)) stop(rel);
|
|
482
|
+
else if (rel === ".claude/settings.json") this.claudeSettings();
|
|
483
|
+
else this.mcpServers(have);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** `.mcp.json` servers the stock's own file lacks go to the overlay `trees` merges back. */
|
|
487
|
+
private mcpServers(have: Record<string, unknown>): void {
|
|
488
|
+
const base = parseJson(this.stockText(".mcp.json"));
|
|
489
|
+
if (!isObject(base)) return;
|
|
490
|
+
const paths: string[] = [];
|
|
491
|
+
const extras = settingsExtras(have, base, "", paths);
|
|
492
|
+
if (extras === undefined) return;
|
|
493
|
+
const to = `${REPO_DIR}/mcp.json`;
|
|
494
|
+
this.write(
|
|
495
|
+
".mcp.json",
|
|
496
|
+
to,
|
|
497
|
+
`${JSON.stringify(extras, null, 2)}\n`,
|
|
498
|
+
`moved your .mcp.json keys ${paths.join(", ")} to ${to}`,
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Lessons move whole; their index is generated, so it stays behind for sync to rewrite. */
|
|
503
|
+
lessons(): void {
|
|
504
|
+
const from = `${COPIED_DIR}/lessons`;
|
|
505
|
+
const names = this.names(from).filter((name) => name !== "INDEX.md");
|
|
506
|
+
if (names.length === 0) return;
|
|
507
|
+
const to = `${REPO_DIR}/lessons`;
|
|
508
|
+
this.move(from, to, `moved ${from}/ (${names.length}) to ${to}/`, ["INDEX.md"]);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Hooks, MCP policy, permissions and global settings that differ from stock move to the same path under `.repo/`. */
|
|
512
|
+
overrides(): void {
|
|
513
|
+
const stockOf = (rel: string) => this.stockText(rel);
|
|
514
|
+
const hooks = `${COPIED_DIR}/hooks.jsonc`;
|
|
515
|
+
if (this.differs(hooks, stockOf(hooks))) {
|
|
516
|
+
this.move(
|
|
517
|
+
hooks,
|
|
518
|
+
`${REPO_DIR}/hooks.jsonc`,
|
|
519
|
+
`moved your ${hooks} to ${REPO_DIR}/hooks.jsonc (it now replaces the stock hooks)`,
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
const policy = `${COPIED_DIR}/mcp-policy.jsonc`;
|
|
523
|
+
if (this.differs(policy, stockOf(policy))) {
|
|
524
|
+
this.move(policy, `${REPO_DIR}/mcp-policy.jsonc`, `moved your ${policy} to ${REPO_DIR}/mcp-policy.jsonc`);
|
|
525
|
+
}
|
|
526
|
+
const permissions = `${COPIED_DIR}/permissions.json`;
|
|
527
|
+
if (this.differs(permissions, stockOf(permissions))) {
|
|
528
|
+
this.move(
|
|
529
|
+
permissions,
|
|
530
|
+
`${REPO_DIR}/permissions.json`,
|
|
531
|
+
`moved your ${permissions} to ${REPO_DIR}/permissions.json`,
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
for (const name of this.names(`${COPIED_DIR}/global-settings`)) {
|
|
535
|
+
const from = `${COPIED_DIR}/global-settings/${name}`;
|
|
536
|
+
if (!isDir(path.join(this.root, from)) && !this.differs(from, stockOf(from))) continue;
|
|
537
|
+
const to = `${REPO_DIR}/global-settings/${name}`;
|
|
538
|
+
this.move(from, to, `moved your ${from} to ${to}${stockOf(from) === null ? "" : REPLACES}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Files of the copied dir that were never meant to be edited (scripts, the pipeline file, the settings) go only when
|
|
544
|
+
* they match a stock version. An edited `settings/claude.json` is the overlay's own file and moves there; any other
|
|
545
|
+
* edited or own file is one nothing reads, so it stops the run.
|
|
546
|
+
*/
|
|
547
|
+
copies(): void {
|
|
548
|
+
for (const dir of ["scripts", "settings"]) {
|
|
549
|
+
const base = `${COPIED_DIR}/${dir}`;
|
|
550
|
+
if (!isDir(path.join(this.root, base))) continue;
|
|
551
|
+
for (const file of filesUnder(path.join(this.root, base))) {
|
|
552
|
+
const from = `${base}/${file}`;
|
|
553
|
+
if (from === COPIED_MARKER || this.unedited(from, this.text(from), this.stockText(from))) continue;
|
|
554
|
+
if (from === CLAUDE_SETTINGS)
|
|
555
|
+
this.move(from, `${REPO_DIR}/settings/claude.json`, `moved your ${from} to ${REPO_DIR}/settings/claude.json`);
|
|
556
|
+
else this.unread(from);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
const pipeline = `${COPIED_DIR}/repo.pipeline.jsonc`;
|
|
560
|
+
if (this.differs(pipeline, this.stockText(pipeline))) this.unread(pipeline);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** Anything else in the copied dir is the consumer's and nothing reads it, so it stops the run. */
|
|
564
|
+
leftovers(): void {
|
|
565
|
+
for (const name of this.names(COPIED_DIR).filter((entry) => !HANDLED.has(entry)))
|
|
566
|
+
this.unread(`${COPIED_DIR}/${name}`);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** A conflict for the user's `rel`: no `.repo/` entry takes it, so it cannot move there. */
|
|
570
|
+
private unread(rel: string): void {
|
|
571
|
+
this.steps.push({
|
|
572
|
+
kind: "conflict",
|
|
573
|
+
from: rel,
|
|
574
|
+
to: "",
|
|
575
|
+
line: `${PREFIX}${rel} is yours and nothing reads it; move it out of ${COPIED_DIR} (to ${REFERENCES_DIR}/ if it explains something) or delete it, then run \`bun repo sync\``,
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** The packs of `repo.features.jsonc` become `agents.skills` in the config. */
|
|
580
|
+
packs(): void {
|
|
581
|
+
const packs = this.featurePacks();
|
|
582
|
+
const text = this.text(CONFIG_FILE);
|
|
583
|
+
if (packs.length === 0 || configHasSkills(text, packs)) return;
|
|
584
|
+
const list = `[${packs.map((pack) => JSON.stringify(pack)).join(", ")}]`;
|
|
585
|
+
const open = /defineConfig\(\s*\{/.exec(text);
|
|
586
|
+
if (open === null || /["']?\bagents\b["']?\s*:/.test(text)) {
|
|
587
|
+
this.steps.push({
|
|
588
|
+
kind: "note",
|
|
589
|
+
note: `could not add the packs ${list} of ${COPIED_DIR}/repo.features.jsonc to ${CONFIG_FILE}; add agents: { skills: ${list} } there`,
|
|
590
|
+
});
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
594
|
+
const at = open.index + open[0].length;
|
|
595
|
+
const inserted = `${text.slice(0, at)}${eol} agents: { skills: ${list} },${text.slice(at)}`;
|
|
596
|
+
this.steps.push({
|
|
597
|
+
kind: "write",
|
|
598
|
+
path: CONFIG_FILE,
|
|
599
|
+
content: withAgentsPack(inserted),
|
|
600
|
+
note: `moved packs ${list} from ${COPIED_DIR}/repo.features.jsonc to agents.skills in ${CONFIG_FILE}`,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** The generated paths git still tracks; none outside git. */
|
|
605
|
+
untrack(candidates: readonly string[], tracked: (root: string, paths: string[]) => string[] | null): void {
|
|
606
|
+
const raw = tracked(this.root, [...candidates]);
|
|
607
|
+
if (raw === null) return;
|
|
608
|
+
const rels = trackedRels(raw, this.root);
|
|
609
|
+
const paths = candidates.filter((entry) =>
|
|
610
|
+
rels.some((rel) => (entry.endsWith("/") ? rel.startsWith(entry) : rel === entry)),
|
|
611
|
+
);
|
|
612
|
+
if (paths.length > 0) this.steps.push({ kind: "untrack", paths });
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Last: the stock copies of skills, then the copied dir. */
|
|
616
|
+
remove(): void {
|
|
617
|
+
this.steps.push({ kind: "remove", paths: [...this.drop, COPIED_DIR] });
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
private featurePacks(): SkillPack[] {
|
|
621
|
+
const text = this.read(`${COPIED_DIR}/repo.features.jsonc`);
|
|
622
|
+
let packs: unknown;
|
|
623
|
+
try {
|
|
624
|
+
packs = (JSON.parse(stripJsonc(text ?? "")) as { packs?: unknown }).packs;
|
|
625
|
+
} catch {
|
|
626
|
+
return [];
|
|
627
|
+
}
|
|
628
|
+
const known = (Array.isArray(packs) ? packs : []).filter((pack): pack is SkillPack =>
|
|
629
|
+
(SKILL_PACKS as readonly unknown[]).includes(pack),
|
|
630
|
+
);
|
|
631
|
+
return [...new Set(known)];
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Moves `from` to `to`, or records the conflict when `to` already holds other text. */
|
|
635
|
+
private move(
|
|
636
|
+
from: string,
|
|
637
|
+
to: string,
|
|
638
|
+
note: string,
|
|
639
|
+
except: readonly string[] = [],
|
|
640
|
+
mark?: { path: string; content: string; note: string },
|
|
641
|
+
): void {
|
|
642
|
+
if (clashes(path.join(this.root, from), path.join(this.root, to), except))
|
|
643
|
+
this.steps.push({ kind: "conflict", from, to });
|
|
644
|
+
else
|
|
645
|
+
this.steps.push({
|
|
646
|
+
kind: "move",
|
|
647
|
+
from,
|
|
648
|
+
to,
|
|
649
|
+
...(except.length > 0 ? { except } : {}),
|
|
650
|
+
note,
|
|
651
|
+
...(mark === undefined ? {} : { mark }),
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** The mark for the file at `from` that moves to `to` and replaces a stock `kind`; undefined when it already says so or cannot be read. */
|
|
656
|
+
private mark(
|
|
657
|
+
from: string,
|
|
658
|
+
to: string,
|
|
659
|
+
name: string,
|
|
660
|
+
kind: "rule" | "subagent" | "skill",
|
|
661
|
+
): { path: string; content: string; note: string } | undefined {
|
|
662
|
+
const text = this.read(from);
|
|
663
|
+
const content = text === null ? null : markedText(text, name);
|
|
664
|
+
if (content === null) return undefined;
|
|
665
|
+
return { path: from, content, note: markNote(to, name, kind) };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** Writes `content` to `to` for text taken from `from`; nothing to do when it is already there. */
|
|
669
|
+
private write(from: string, to: string, content: string, note: string): void {
|
|
670
|
+
const existing = this.read(to);
|
|
671
|
+
if (existing === content || (existing !== null && alreadyHolds(to, existing, content))) return;
|
|
672
|
+
if (existing !== null) this.steps.push({ kind: "conflict", from, to });
|
|
673
|
+
else this.steps.push({ kind: "write", path: to, content, note });
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
private differs(rel: string, stockText: string | null): boolean {
|
|
677
|
+
const own = this.read(rel);
|
|
678
|
+
return own !== null && !this.unedited(rel, own, stockText);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** Whether `text` is an unedited stock file: the stock's current one, or a version in the history table. */
|
|
682
|
+
private unedited(rel: string, text: string, stockText: string | null, also: readonly string[] = []): boolean {
|
|
683
|
+
if (stockText !== null && same(text, stockText)) return true;
|
|
684
|
+
const hash = hashText(text);
|
|
685
|
+
return [rel, ...also].some((key) => this.history[key]?.includes(hash) === true);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
private names(rel: string): string[] {
|
|
689
|
+
try {
|
|
690
|
+
return fs.readdirSync(path.join(this.root, rel)).sort();
|
|
691
|
+
} catch {
|
|
692
|
+
return [];
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
private read(rel: string): string | null {
|
|
697
|
+
try {
|
|
698
|
+
return fs.readFileSync(path.join(this.root, rel), "utf8");
|
|
699
|
+
} catch {
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** A file the plan found by listing its dir. */
|
|
705
|
+
private text(rel: string): string {
|
|
706
|
+
return fs.readFileSync(path.join(this.root, rel), "utf8");
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** The stock version of the file at `rel`: the text sync generates there, else the package's file; null when neither has one. */
|
|
710
|
+
private stockText(rel: string): string | null {
|
|
711
|
+
const generated = this.generated.get(rel);
|
|
712
|
+
if (generated !== undefined) return generated;
|
|
713
|
+
try {
|
|
714
|
+
return fs.readFileSync(path.join(this.stock, stockRel(rel)), "utf8");
|
|
715
|
+
} catch {
|
|
716
|
+
return null;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const markNote = (rel: string, name: string, kind: string) =>
|
|
722
|
+
`marked ${rel} with overrides: ${name} (it replaces the stock ${kind})`;
|
|
723
|
+
|
|
724
|
+
/** `text` with `overrides: name` as the first frontmatter key (or a new frontmatter block); null when it already has one. */
|
|
725
|
+
function markedText(text: string, name: string): string | null {
|
|
726
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
727
|
+
const lines = text.split(/\r?\n/);
|
|
728
|
+
const bounds = frontmatterBounds(lines);
|
|
729
|
+
if (bounds !== null) {
|
|
730
|
+
try {
|
|
731
|
+
if (parseDoc("", text).data["overrides"] !== undefined) return null;
|
|
732
|
+
} catch {
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
const line = emitYaml([["overrides", name]]).trimEnd();
|
|
737
|
+
return bounds === null ? ["---", line, "---", ...lines].join(eol) : [lines[0]!, line, ...lines.slice(1)].join(eol);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** The lines between the fences as `[open, close]` indexes; null without a closed frontmatter block. */
|
|
741
|
+
function frontmatterBounds(lines: readonly string[]): [number, number] | null {
|
|
742
|
+
if (lines[0]?.trimEnd() !== "---") return null;
|
|
743
|
+
const close = lines.findIndex((line, i) => i > 0 && line.trimEnd() === "---");
|
|
744
|
+
return close === -1 ? null : [0, close];
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const OLD_KEYS = ["root", "targets", "globs"] as const;
|
|
748
|
+
|
|
749
|
+
/** `a`, `a and b` or `a, b and c`. */
|
|
750
|
+
function joinAnd(items: readonly string[]): string {
|
|
751
|
+
return items.length < 2 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** The names under `<dir>/<kind>/` of a stock package, case-folded and without `.md`, nested paths included. */
|
|
755
|
+
function stockNames(stock: string, kind: "rules" | "subagents"): Set<string> {
|
|
756
|
+
const dir = path.join(stock, "agents", kind);
|
|
757
|
+
return new Set(
|
|
758
|
+
isDir(dir)
|
|
759
|
+
? filesUnder(dir)
|
|
760
|
+
.filter((file) => file.endsWith(".md"))
|
|
761
|
+
.map((file) => file.slice(0, -3).toLowerCase())
|
|
762
|
+
: [],
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/** The `.md` files under `<root>/.repo/<kind>`, POSIX paths relative to it, dot entries skipped, in string order. */
|
|
767
|
+
function ownDocs(root: string, kind: "rules" | "subagents"): string[] {
|
|
768
|
+
const base = path.join(root, REPO_DIR, kind);
|
|
769
|
+
if (!isDir(base)) return [];
|
|
770
|
+
return filesUnder(base).filter(
|
|
771
|
+
(file) => file.endsWith(".md") && !file.split("/").some((part) => part.startsWith(".")),
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* The steps that bring a `.repo/` rule or subagent still in the old format to the new one, once: a rule loses
|
|
777
|
+
* `root`, `targets` and `globs` (`globs` become `paths`, none when they cover every file) and gains a `description`
|
|
778
|
+
* from its first `#` heading, else its name; a subagent loses `root` and `globs`, and keeps `targets`, which its
|
|
779
|
+
* generator still reads. Such a file that bears a stock name is marked `overrides: <name>`, since an earlier sync
|
|
780
|
+
* let it replace the stock one. A file in the new format is never touched or marked, so a new same-name file still
|
|
781
|
+
* fails. Each step is the whole new file, so it is planned only while the file is old and a second run plans nothing.
|
|
782
|
+
* A file whose frontmatter does not parse is left for the loader to report.
|
|
783
|
+
*/
|
|
784
|
+
export function planRuleUpgrade(root: string, stock: string): MigrationStep[] {
|
|
785
|
+
const steps: MigrationStep[] = [];
|
|
786
|
+
for (const kind of ["rules", "subagents"] as const) {
|
|
787
|
+
const stocked = stockNames(stock, kind);
|
|
788
|
+
for (const file of ownDocs(root, kind)) {
|
|
789
|
+
const rel = `${REPO_DIR}/${kind}/${file}`;
|
|
790
|
+
const text = fs.readFileSync(path.join(root, rel), "utf8");
|
|
791
|
+
const name = file.slice(0, -3);
|
|
792
|
+
const upgraded = upgradeText(text, name, kind, stocked.has(name.toLowerCase()));
|
|
793
|
+
if (upgraded !== null)
|
|
794
|
+
steps.push({
|
|
795
|
+
kind: "write",
|
|
796
|
+
path: rel,
|
|
797
|
+
content: upgraded.content,
|
|
798
|
+
note: upgraded.notes.map((note) => note.replace("@", rel)).join("\n"),
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return steps;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** The new text of an old-format rule or subagent and the lines to print for it (`@` stands for its path); null when it needs nothing. */
|
|
806
|
+
function upgradeText(
|
|
807
|
+
text: string,
|
|
808
|
+
name: string,
|
|
809
|
+
kind: "rules" | "subagents",
|
|
810
|
+
stocked: boolean,
|
|
811
|
+
): { content: string; notes: string[] } | null {
|
|
812
|
+
let data: Record<string, unknown>;
|
|
813
|
+
let body: string;
|
|
814
|
+
try {
|
|
815
|
+
({ data, body } = parseDoc("", text));
|
|
816
|
+
} catch {
|
|
817
|
+
return null;
|
|
818
|
+
}
|
|
819
|
+
const carried = OLD_KEYS.filter((key) => key in data);
|
|
820
|
+
if (carried.length === 0) return null;
|
|
821
|
+
const isRule = kind === "rules";
|
|
822
|
+
const removed = carried.filter((key) => isRule || key !== "targets");
|
|
823
|
+
const dropped = [...removed];
|
|
824
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
825
|
+
const lines = text.split(/\r?\n/);
|
|
826
|
+
const [, close] = frontmatterBounds(lines)!;
|
|
827
|
+
|
|
828
|
+
const top: [string, string | string[]][] = [];
|
|
829
|
+
const parts: string[] = [];
|
|
830
|
+
if (isRule) {
|
|
831
|
+
if (typeof data["description"] !== "string" || data["description"].trim() === "") {
|
|
832
|
+
top.push(["description", (/^#\s+(.+?)\s*#*\s*$/m.exec(body)?.[1] ?? name.split("/").at(-1)!).trim()]);
|
|
833
|
+
parts.push("description added");
|
|
834
|
+
}
|
|
835
|
+
const globs = globList(data["globs"]);
|
|
836
|
+
const everywhere = globs.some((glob) => glob === "**/*" || glob === "*");
|
|
837
|
+
if (data["paths"] === undefined && globs.length > 0 && !everywhere) {
|
|
838
|
+
top.push(["paths", globs]);
|
|
839
|
+
parts.push("globs → paths");
|
|
840
|
+
dropped.splice(dropped.indexOf("globs"), 1);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
const mark = stocked && data["overrides"] === undefined;
|
|
844
|
+
if (mark) top.push(["overrides", name]);
|
|
845
|
+
if (dropped.length > 0) parts.push(`${joinAnd(dropped)} dropped`);
|
|
846
|
+
|
|
847
|
+
const kept: string[] = [];
|
|
848
|
+
let skipping = false;
|
|
849
|
+
for (const line of lines.slice(1, close)) {
|
|
850
|
+
const key = /^([A-Za-z_]+)\s*:/.exec(line)?.[1];
|
|
851
|
+
if (key !== undefined && removed.includes(key as (typeof OLD_KEYS)[number])) skipping = true;
|
|
852
|
+
else if (skipping && (/^\s/.test(line) || /^-(\s|$)/.test(line))) continue;
|
|
853
|
+
else {
|
|
854
|
+
skipping = false;
|
|
855
|
+
kept.push(line);
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
const head = emitYaml(top)
|
|
860
|
+
.split("\n")
|
|
861
|
+
.filter((line) => line !== "");
|
|
862
|
+
const content = [lines[0]!, ...head, ...kept, ...lines.slice(close)].join(eol);
|
|
863
|
+
const notes = [
|
|
864
|
+
...(parts.length > 0 ? [`rewrote @ frontmatter (${parts.join(", ")})`] : []),
|
|
865
|
+
...(mark ? [markNote("@", name, isRule ? "rule" : "subagent")] : []),
|
|
866
|
+
];
|
|
867
|
+
return notes.length === 0 ? null : { content, notes };
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/** Globs from a `globs` value: a list, or a comma-separated string. */
|
|
871
|
+
function globList(value: unknown): string[] {
|
|
872
|
+
const items = Array.isArray(value) ? value.map(String) : typeof value === "string" ? value.split(",") : [];
|
|
873
|
+
return items.map((item) => item.trim()).filter((item) => item !== "");
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/** The parsed file; undefined when it is missing, null when it is not valid JSON. */
|
|
877
|
+
function readJson(file: string): unknown {
|
|
878
|
+
try {
|
|
879
|
+
return parseJson(fs.readFileSync(file, "utf8"));
|
|
880
|
+
} catch {
|
|
881
|
+
return undefined;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/** The JSON value of `text`: undefined for no text, null when it is not JSON. */
|
|
886
|
+
function parseJson(text: string | null): unknown {
|
|
887
|
+
if (text === null) return undefined;
|
|
888
|
+
try {
|
|
889
|
+
return JSON.parse(text);
|
|
890
|
+
} catch {
|
|
891
|
+
return null;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
896
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* What `have` holds that `base` does not, as the value to merge over `base` again, with the dotted path of each
|
|
901
|
+
* piece in `paths`: new keys whole, list items `base` lacks, scalars that differ. Undefined when there is none.
|
|
902
|
+
*/
|
|
903
|
+
function settingsExtras(have: unknown, base: unknown, at: string, paths: string[]): unknown {
|
|
904
|
+
if (Array.isArray(have) && Array.isArray(base)) {
|
|
905
|
+
const extra = have.filter((item) => !base.some((known) => isDeepStrictEqual(known, item)));
|
|
906
|
+
if (extra.length === 0) return undefined;
|
|
907
|
+
paths.push(at);
|
|
908
|
+
return extra;
|
|
909
|
+
}
|
|
910
|
+
if (isObject(have) && isObject(base)) {
|
|
911
|
+
const found: Record<string, unknown> = {};
|
|
912
|
+
for (const [key, value] of Object.entries(have)) {
|
|
913
|
+
const next = at === "" ? key : `${at}.${key}`;
|
|
914
|
+
const extra = key in base ? settingsExtras(value, base[key], next, paths) : keep(value, next, paths);
|
|
915
|
+
if (extra !== undefined) found[key] = extra;
|
|
916
|
+
}
|
|
917
|
+
return Object.keys(found).length > 0 ? found : undefined;
|
|
918
|
+
}
|
|
919
|
+
return isDeepStrictEqual(have, base) ? undefined : keep(have, at, paths);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* The hooks of `hooks` (a settings file's `hooks` value) that run a command `known` does not: per event, the groups
|
|
924
|
+
* cut down to their unknown hooks. `events` lists each carried event with its group count.
|
|
925
|
+
*/
|
|
926
|
+
function ownHooks(
|
|
927
|
+
hooks: unknown,
|
|
928
|
+
known: (command: string) => boolean,
|
|
929
|
+
): { hooks: Record<string, unknown[]>; events: [string, number][] } {
|
|
930
|
+
const carried: Record<string, unknown[]> = {};
|
|
931
|
+
if (isObject(hooks)) {
|
|
932
|
+
for (const [event, value] of Object.entries(hooks)) {
|
|
933
|
+
const groups = (Array.isArray(value) ? value : [value]).flatMap((group: unknown) => {
|
|
934
|
+
if (!isObject(group) || !Array.isArray(group.hooks)) return [group];
|
|
935
|
+
const unknown = group.hooks.filter(
|
|
936
|
+
(hook: unknown) => !(isObject(hook) && typeof hook.command === "string" && known(hook.command)),
|
|
937
|
+
);
|
|
938
|
+
return unknown.length > 0 ? [{ ...group, hooks: unknown }] : [];
|
|
939
|
+
});
|
|
940
|
+
if (groups.length > 0) carried[event] = groups;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
return { hooks: carried, events: Object.entries(carried).map(([event, groups]) => [event, groups.length]) };
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function keep(value: unknown, at: string, paths: string[]): unknown {
|
|
947
|
+
paths.push(at);
|
|
948
|
+
return value;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/** The config text with "agents" added to its `packs` list when it has one without it; agents.skills needs the pack. */
|
|
952
|
+
function withAgentsPack(text: string): string {
|
|
953
|
+
return text.replace(/(\bpacks\s*:\s*\[)([^\]]*)\]/, (whole, head: string, items: string) => {
|
|
954
|
+
if (/["']agents["']/.test(items)) return whole;
|
|
955
|
+
const kept = items.trimEnd();
|
|
956
|
+
return `${head}${kept}${kept === "" ? "" : ", "}"agents"]`;
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** Whether the config text already lists every pack in an `agents.skills` array. */
|
|
961
|
+
function configHasSkills(text: string, packs: readonly string[]): boolean {
|
|
962
|
+
const list = /\bskills\s*:\s*\[([^\]]*)\]/.exec(text)?.[1];
|
|
963
|
+
return list !== undefined && packs.every((pack) => list.includes(`"${pack}"`) || list.includes(`'${pack}'`));
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/** Whether `existing`, the file already at `to`, holds everything `content` would write there: the rule text, or every key and list item. */
|
|
967
|
+
function alreadyHolds(to: string, existing: string, content: string): boolean {
|
|
968
|
+
if (to.endsWith(".md")) {
|
|
969
|
+
// every markdown file `write` makes is a rule, which starts with the same frontmatter
|
|
970
|
+
return existing.includes(content.slice(RULE_FRONTMATTER.length).trim());
|
|
971
|
+
}
|
|
972
|
+
try {
|
|
973
|
+
return covers(JSON.parse(existing), JSON.parse(content));
|
|
974
|
+
} catch {
|
|
975
|
+
return false;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/** Whether `have` has every key of `want` (recursively) and every list item of it. */
|
|
980
|
+
function covers(have: unknown, want: unknown): boolean {
|
|
981
|
+
if (Array.isArray(want))
|
|
982
|
+
return Array.isArray(have) && want.every((item) => have.some((known) => isDeepStrictEqual(known, item)));
|
|
983
|
+
if (isObject(want))
|
|
984
|
+
return isObject(have) && Object.entries(want).every(([key, value]) => key in have && covers(have[key], value));
|
|
985
|
+
return isDeepStrictEqual(have, want);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/** The first `# ` heading line of a markdown text, or null. */
|
|
989
|
+
function h1Of(text: string): string | null {
|
|
990
|
+
return /^# .+$/m.exec(text)?.[0].trimEnd() ?? null;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/** `"A", "B"`: the titles of the `## ` sections in `sections`. */
|
|
994
|
+
function titlesOf(sections: string): string {
|
|
995
|
+
return [...sections.matchAll(/^## (.+)$/gm)].map((match) => `"${match[1]!.trim()}"`).join(", ");
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/** Whether anything, a dangling link included, is at `abs`. */
|
|
999
|
+
function present(abs: string): boolean {
|
|
1000
|
+
return fs.lstatSync(abs, { throwIfNoEntry: false }) !== undefined;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/** Whether `abs` is a directory itself, not a link to one. */
|
|
1004
|
+
function isRealDir(abs: string): boolean {
|
|
1005
|
+
return fs.lstatSync(abs, { throwIfNoEntry: false })?.isDirectory() === true;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function isDir(abs: string): boolean {
|
|
1009
|
+
try {
|
|
1010
|
+
return fs.lstatSync(abs, { throwIfNoEntry: false })?.isDirectory() === true;
|
|
1011
|
+
} catch {
|
|
1012
|
+
return false;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/** Equal text, whatever the line endings. */
|
|
1017
|
+
function same(a: string, b: string): boolean {
|
|
1018
|
+
return a.replaceAll("\r\n", "\n") === b.replaceAll("\r\n", "\n");
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/** Every file under `dir` as a `/` path relative to it. */
|
|
1022
|
+
function filesUnder(dir: string): string[] {
|
|
1023
|
+
return fs
|
|
1024
|
+
.readdirSync(dir, { recursive: true, withFileTypes: true })
|
|
1025
|
+
.filter((entry) => !entry.isDirectory())
|
|
1026
|
+
.map((entry) => path.relative(dir, path.join(entry.parentPath, entry.name)).replaceAll("\\", "/"))
|
|
1027
|
+
.sort();
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/** Whether moving `src` to `dst` would overwrite other text; children named in `except` do not move. */
|
|
1031
|
+
function clashes(src: string, dst: string, except: readonly string[]): boolean {
|
|
1032
|
+
if (!present(dst)) return false;
|
|
1033
|
+
if (isDir(src) && isDir(dst)) {
|
|
1034
|
+
return fs
|
|
1035
|
+
.readdirSync(src)
|
|
1036
|
+
.filter((name) => !except.includes(name))
|
|
1037
|
+
.some((name) => clashes(path.join(src, name), path.join(dst, name), []));
|
|
1038
|
+
}
|
|
1039
|
+
if (isDir(src) || isDir(dst)) return true;
|
|
1040
|
+
return !same(fs.readFileSync(src, "utf8"), fs.readFileSync(dst, "utf8"));
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/** Renames `src` to `dst`; into an existing dir it goes entry by entry, and an equal file just goes. */
|
|
1044
|
+
function moveTree(src: string, dst: string, except: readonly string[]): void {
|
|
1045
|
+
if (!present(dst) && except.length === 0) {
|
|
1046
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
1047
|
+
fs.renameSync(src, dst);
|
|
1048
|
+
} else if (isDir(src)) {
|
|
1049
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
1050
|
+
for (const name of fs.readdirSync(src).filter((entry) => !except.includes(entry))) {
|
|
1051
|
+
moveTree(path.join(src, name), path.join(dst, name), []);
|
|
1052
|
+
}
|
|
1053
|
+
if (except.length === 0) fs.rmdirSync(src);
|
|
1054
|
+
} else if (same(fs.readFileSync(src, "utf8"), fs.readFileSync(dst, "utf8"))) fs.rmSync(src);
|
|
1055
|
+
else throw new Error(`cannot move ${src}: ${dst} already exists and differs`);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/** Writes a step's file through a temp file and a rename; the config is checked after and restored when it fails. */
|
|
1059
|
+
async function writeStep(root: string, step: Extract<MigrationStep, { kind: "write" }>): Promise<string> {
|
|
1060
|
+
const file = path.join(root, step.path);
|
|
1061
|
+
const before = step.path === CONFIG_FILE ? fs.readFileSync(file, "utf8") : null;
|
|
1062
|
+
writeAtomic(file, step.content);
|
|
1063
|
+
if (before === null) return step.note;
|
|
1064
|
+
const wanted = /skills: (\[[^\]]*\])/.exec(step.content)![1]!;
|
|
1065
|
+
const loaded = await loadConfig(root);
|
|
1066
|
+
if (loaded.ok && (JSON.parse(wanted) as SkillPack[]).every((pack) => loaded.config.agents.skills.includes(pack))) {
|
|
1067
|
+
return step.note;
|
|
1068
|
+
}
|
|
1069
|
+
writeAtomic(file, before);
|
|
1070
|
+
return `could not add agents.skills ${wanted} to ${CONFIG_FILE}; add agents: { skills: ${wanted} } there`;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function writeAtomic(file: string, content: string): void {
|
|
1074
|
+
const temp = path.join(path.dirname(file), `.${path.basename(file)}.${randomBytes(6).toString("hex")}.tmp`);
|
|
1075
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1076
|
+
fs.writeFileSync(temp, content);
|
|
1077
|
+
fs.renameSync(temp, file);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/** Which of `paths` git tracks, as git prints them; null outside a git repo. */
|
|
1081
|
+
function gitTracked(root: string, paths: string[]): string[] | null {
|
|
1082
|
+
if (!present(path.join(root, ".git"))) return null;
|
|
1083
|
+
const result = spawnSync("git", ["ls-files", "-z", "--", ...paths], { cwd: root, encoding: "utf8" });
|
|
1084
|
+
return result.status === 0 ? result.stdout.split("\0").filter((entry) => entry !== "") : null;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/** Takes `paths` out of the index and leaves the files, so sync can regenerate them as ignored. */
|
|
1088
|
+
function untrack(root: string, paths: readonly string[]): void {
|
|
1089
|
+
const result = spawnSync("git", ["rm", "-r", "--cached", "-q", "--", ...paths], { cwd: root, encoding: "utf8" });
|
|
1090
|
+
if (result.status !== 0) throw new Error(`git rm --cached failed: ${result.stderr.trim()}`);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/** Removes `paths`; the copied dir loses everything but the marker first, so the marker is the last file to go. */
|
|
1094
|
+
function removeAll(root: string, paths: readonly string[]): void {
|
|
1095
|
+
for (const rel of paths) {
|
|
1096
|
+
const abs = path.join(root, rel);
|
|
1097
|
+
if (rel === COPIED_DIR) {
|
|
1098
|
+
for (const file of filesUnder(abs).filter((entry) => `${COPIED_DIR}/${entry}` !== COPIED_MARKER)) {
|
|
1099
|
+
fs.rmSync(path.join(abs, file), { force: true });
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
fs.rmSync(abs, { recursive: true, force: true });
|
|
1103
|
+
}
|
|
1104
|
+
}
|