@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,9 @@
|
|
|
1
|
+
/** Dependency-free JSONC helpers. The published CLI imports this, so keep it to node builtins. */
|
|
2
|
+
|
|
3
|
+
/** Drop `//` and `/* *\/` comments outside JSON strings; comment markers inside strings stay. */
|
|
4
|
+
export function stripJsonc(text: unknown): string {
|
|
5
|
+
return String(text ?? "").replace(
|
|
6
|
+
/("(?:[^"\\]|\\.)*")|\/\*[\s\S]*?\*\/|\/\/[^\n]*/g,
|
|
7
|
+
(_match: string, str?: string) => str ?? "",
|
|
8
|
+
);
|
|
9
|
+
}
|
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/** Lessons: one committed file per repeat mistake under .repo/lessons/, plus a generated index. */
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { mainCheckoutWriteReason, managedLessonsDir } from "./worktree.ts";
|
|
7
|
+
import { canonicalPath, resolveRoots } from "./workspace-root.ts";
|
|
8
|
+
|
|
9
|
+
export const LESSONS_DIR = ".repo/lessons";
|
|
10
|
+
export const INDEX_NAME = "INDEX.md";
|
|
11
|
+
export const MAX_LINES = 60;
|
|
12
|
+
export const MAX_EVIDENCE = 5;
|
|
13
|
+
export const DEFAULT_CAP = 10;
|
|
14
|
+
const BANNER = "<!-- Generated by lessons.ts. Do not hand-edit. -->";
|
|
15
|
+
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
16
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
17
|
+
const KEYS = ["name", "paths", "hits", "last-hit", "evidence"];
|
|
18
|
+
const SKIP_WALK = new Set([".git", "node_modules", ".tmp"]);
|
|
19
|
+
const USAGE = "usage: bun repo lessons match <file...> | hit <slug> --evidence <text> | rm <slug> | index | check";
|
|
20
|
+
|
|
21
|
+
/** One parsed lesson file. */
|
|
22
|
+
export interface Lesson {
|
|
23
|
+
name: string;
|
|
24
|
+
paths: string[];
|
|
25
|
+
hits: number;
|
|
26
|
+
lastHit: string;
|
|
27
|
+
evidence: string[];
|
|
28
|
+
title: string;
|
|
29
|
+
summary: string;
|
|
30
|
+
mistake: string;
|
|
31
|
+
fix: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ParsedLesson = { ok: true; lesson: Lesson } | { ok: false; errors: string[] };
|
|
35
|
+
|
|
36
|
+
/** One INDEX.md row. */
|
|
37
|
+
export interface IndexRow {
|
|
38
|
+
file: string;
|
|
39
|
+
paths: string[];
|
|
40
|
+
hits: number;
|
|
41
|
+
lastHit: string;
|
|
42
|
+
summary: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The roots `resolveRoots` returns. */
|
|
46
|
+
export interface Roots {
|
|
47
|
+
root: string;
|
|
48
|
+
checkout: string;
|
|
49
|
+
cwd: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type LessonResult =
|
|
53
|
+
| { ok: true; path: string; content: string; error?: undefined }
|
|
54
|
+
| { ok: false; error: string; path?: undefined; content?: undefined };
|
|
55
|
+
export type HitResult =
|
|
56
|
+
| { ok: true; path: string; hits: number; error?: undefined }
|
|
57
|
+
| { ok: false; error: string; path?: undefined; hits?: undefined };
|
|
58
|
+
export type RemoveResult = { ok: true; error?: undefined } | { ok: false; error: string };
|
|
59
|
+
|
|
60
|
+
function utcDate(d = new Date()) {
|
|
61
|
+
return d.toISOString().slice(0, 10);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function toPosix(p: string) {
|
|
65
|
+
return p.split(path.sep).join("/");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function lessonsDir(ws: string) {
|
|
69
|
+
return path.join(ws, LESSONS_DIR);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function lessonPath(ws: string, slug: string) {
|
|
73
|
+
return path.join(lessonsDir(ws), `${slug}.md`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Glob to an anchored RegExp over repo-relative posix paths. `**` spans dirs; `*` and `?` stay in one segment. */
|
|
77
|
+
export function globToRegExp(glob: unknown): RegExp {
|
|
78
|
+
let re = "";
|
|
79
|
+
const g = String(glob);
|
|
80
|
+
for (let i = 0; i < g.length; i++) {
|
|
81
|
+
const c = g.charAt(i);
|
|
82
|
+
if (c === "*" && g[i + 1] === "*") {
|
|
83
|
+
if (g[i + 2] === "/") {
|
|
84
|
+
re += "(?:.*/)?";
|
|
85
|
+
i += 2;
|
|
86
|
+
} else {
|
|
87
|
+
re += ".*";
|
|
88
|
+
i += 1;
|
|
89
|
+
}
|
|
90
|
+
} else if (c === "*") re += "[^/]*";
|
|
91
|
+
else if (c === "?") re += "[^/]";
|
|
92
|
+
else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
93
|
+
}
|
|
94
|
+
return new RegExp(`^${re}$`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readFrontmatter(text: unknown): { map: Record<string, string>; body: string } | null {
|
|
98
|
+
// A Windows checkout with core.autocrlf turns a committed lesson's LF into CRLF.
|
|
99
|
+
const s = String(text).replace(/^/, "").replace(/\r\n/g, "\n");
|
|
100
|
+
if (!s.startsWith("---\n")) return null;
|
|
101
|
+
const end = s.indexOf("\n---\n", 4);
|
|
102
|
+
if (end === -1) return null;
|
|
103
|
+
const map: Record<string, string> = {};
|
|
104
|
+
for (const line of s.slice(4, end).split("\n")) {
|
|
105
|
+
const m = line.match(/^([a-z-]+):\s*(.*)$/);
|
|
106
|
+
if (m)
|
|
107
|
+
map[m[1] /* group 1 is set whenever the pattern matches */ as string] = (
|
|
108
|
+
m[2] /* group 2 is set whenever the pattern matches */ as string
|
|
109
|
+
).trim();
|
|
110
|
+
}
|
|
111
|
+
return { map, body: s.slice(end + 5) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function jsonList(raw: string): string[] | null {
|
|
115
|
+
try {
|
|
116
|
+
const v = JSON.parse(raw);
|
|
117
|
+
return Array.isArray(v) && v.every((x) => typeof x === "string") ? v : null;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Split the body into title, summary, and the Mistake/Fix sections. */
|
|
124
|
+
function readBody(body: unknown) {
|
|
125
|
+
const errors: string[] = [];
|
|
126
|
+
const lines = String(body).split("\n");
|
|
127
|
+
const h1 = lines.findIndex((l) => /^# \S/.test(l));
|
|
128
|
+
if (h1 === -1) errors.push("start the body with a # title line");
|
|
129
|
+
const title = h1 === -1 ? "" : (lines[h1] /* findIndex returned a valid index */ as string).slice(2).trim();
|
|
130
|
+
const summary = (lines.slice(h1 + 1).find((l) => l.trim() && !l.startsWith("#")) || "").trim();
|
|
131
|
+
const firstH2 = lines.findIndex((l) => l.startsWith("## "));
|
|
132
|
+
if (!summary || (firstH2 !== -1 && lines.indexOf(summary) > firstH2)) {
|
|
133
|
+
errors.push("put a one-line summary under the # title");
|
|
134
|
+
}
|
|
135
|
+
const h2 = lines.map((l, i) => ({ l, i })).filter(({ l }) => l.startsWith("## "));
|
|
136
|
+
const names = h2.map(({ l }) => l.slice(3).trim());
|
|
137
|
+
const sections: Record<string, string> = {};
|
|
138
|
+
h2.forEach(({ l, i }, k) => {
|
|
139
|
+
sections[l.slice(3).trim()] = lines
|
|
140
|
+
.slice(i + 1, h2[k + 1]?.i ?? lines.length)
|
|
141
|
+
.join("\n")
|
|
142
|
+
.trim();
|
|
143
|
+
});
|
|
144
|
+
for (const need of ["Mistake", "Fix"]) {
|
|
145
|
+
if (!names.includes(need)) errors.push(`missing ## ${need}`);
|
|
146
|
+
else if (!sections[need]) errors.push(`## ${need} is empty`);
|
|
147
|
+
}
|
|
148
|
+
if (names.length !== 2 || names[0] !== "Mistake" || names[1] !== "Fix") {
|
|
149
|
+
errors.push("use only ## Mistake then ## Fix as section headings");
|
|
150
|
+
}
|
|
151
|
+
return { title, summary, mistake: sections.Mistake || "", fix: sections.Fix || "", errors };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Parse a lesson file. `defaults` fills missing hits and last-hit (a first write). */
|
|
155
|
+
export function parseLesson(text: unknown, { defaults }: { defaults?: Record<string, string> } = {}): ParsedLesson {
|
|
156
|
+
const fm = readFrontmatter(text);
|
|
157
|
+
if (!fm) return { ok: false, errors: ["start with --- frontmatter (name, paths, hits, last-hit, evidence)"] };
|
|
158
|
+
const errors: string[] = [];
|
|
159
|
+
const map: Record<string, string> = { ...(defaults || {}), ...fm.map };
|
|
160
|
+
for (const k of Object.keys(fm.map)) if (!KEYS.includes(k)) errors.push(`unknown frontmatter key: ${k}`);
|
|
161
|
+
const name = map.name ?? "";
|
|
162
|
+
const lastHit = map["last-hit"] ?? "";
|
|
163
|
+
const paths = jsonList(map.paths ?? "");
|
|
164
|
+
if (!paths || !paths.length) errors.push('paths must list at least one glob, e.g. paths: ["src/**"]');
|
|
165
|
+
const evidence = jsonList(map.evidence ?? "[]");
|
|
166
|
+
if (!evidence) errors.push('evidence must be a JSON list, e.g. evidence: ["plan 16"]');
|
|
167
|
+
const hits = Number(map.hits);
|
|
168
|
+
if (!Number.isInteger(hits) || hits < 1) errors.push("hits must be a whole number ≥ 1");
|
|
169
|
+
if (!DATE_RE.test(lastHit)) errors.push("last-hit must be YYYY-MM-DD");
|
|
170
|
+
if (!SLUG_RE.test(name)) errors.push("name must be the lowercase-hyphen slug of the filename");
|
|
171
|
+
const body = readBody(fm.body);
|
|
172
|
+
errors.push(...body.errors);
|
|
173
|
+
if (errors.length || !paths || !evidence) return { ok: false, errors };
|
|
174
|
+
return {
|
|
175
|
+
ok: true,
|
|
176
|
+
lesson: {
|
|
177
|
+
name,
|
|
178
|
+
paths,
|
|
179
|
+
hits,
|
|
180
|
+
lastHit,
|
|
181
|
+
evidence,
|
|
182
|
+
title: body.title,
|
|
183
|
+
summary: body.summary,
|
|
184
|
+
mistake: body.mistake,
|
|
185
|
+
fix: body.fix,
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function renderLesson(l: Lesson) {
|
|
191
|
+
return `---
|
|
192
|
+
name: ${l.name}
|
|
193
|
+
paths: ${JSON.stringify(l.paths)}
|
|
194
|
+
hits: ${l.hits}
|
|
195
|
+
last-hit: ${l.lastHit}
|
|
196
|
+
evidence: ${JSON.stringify(l.evidence)}
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
# ${l.title}
|
|
200
|
+
|
|
201
|
+
${l.summary}
|
|
202
|
+
|
|
203
|
+
## Mistake
|
|
204
|
+
${l.mistake}
|
|
205
|
+
|
|
206
|
+
## Fix
|
|
207
|
+
${l.fix}
|
|
208
|
+
`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Repo-relative files: tracked and untracked-but-not-ignored in git, else a walk. */
|
|
212
|
+
export function repoFiles(ws: string): string[] {
|
|
213
|
+
const git = spawnSync("git", ["ls-files", "-co", "--exclude-standard"], { cwd: ws, encoding: "utf8" });
|
|
214
|
+
if (git.status === 0) {
|
|
215
|
+
return git.stdout.split("\n").filter(Boolean);
|
|
216
|
+
}
|
|
217
|
+
const out: string[] = [];
|
|
218
|
+
const walk = (dir: string) => {
|
|
219
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
220
|
+
if (SKIP_WALK.has(ent.name)) continue;
|
|
221
|
+
const abs = path.join(dir, ent.name);
|
|
222
|
+
if (ent.isDirectory()) walk(abs);
|
|
223
|
+
else if (ent.isFile()) out.push(toPosix(path.relative(ws, abs)));
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
walk(ws);
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function validateLesson(filename: string, text: unknown, { files }: { files?: string[] } = {}): string[] {
|
|
231
|
+
const errors: string[] = [];
|
|
232
|
+
const lineCount = String(text).replace(/\n$/, "").split("\n").length;
|
|
233
|
+
if (lineCount > MAX_LINES) {
|
|
234
|
+
errors.push(`lesson is ${lineCount} lines; the cap is ${MAX_LINES} lines, so cut it or split the mistake`);
|
|
235
|
+
}
|
|
236
|
+
const parsed = parseLesson(text);
|
|
237
|
+
if (!parsed.ok) return [...errors, ...parsed.errors];
|
|
238
|
+
const slug = path.basename(filename, ".md");
|
|
239
|
+
if (parsed.lesson.name !== slug) errors.push(`name ${parsed.lesson.name} must match the filename ${slug}`);
|
|
240
|
+
if (files) {
|
|
241
|
+
for (const glob of parsed.lesson.paths) {
|
|
242
|
+
const re = globToRegExp(glob);
|
|
243
|
+
if (!files.some((f) => re.test(f)))
|
|
244
|
+
errors.push(`paths glob ${glob} matches no file; point it at code that exists`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return errors;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function lessonFiles(ws: string) {
|
|
251
|
+
const dir = lessonsDir(ws);
|
|
252
|
+
if (!fs.existsSync(dir)) return [];
|
|
253
|
+
return fs
|
|
254
|
+
.readdirSync(dir)
|
|
255
|
+
.filter((n) => n.endsWith(".md") && n !== INDEX_NAME)
|
|
256
|
+
.sort();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function cell(s: unknown) {
|
|
260
|
+
return String(s).replace(/\s+/g, " ").replace(/\|/g, "\\|");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** `pending` maps a lesson filename to text the host is about to write. */
|
|
264
|
+
function indexRows(ws: string, pending: Record<string, string> = {}): IndexRow[] {
|
|
265
|
+
const rows: IndexRow[] = [];
|
|
266
|
+
const names = [...new Set([...lessonFiles(ws), ...Object.keys(pending)])].sort();
|
|
267
|
+
for (const name of names) {
|
|
268
|
+
const text = pending[name] ?? fs.readFileSync(path.join(lessonsDir(ws), name), "utf8");
|
|
269
|
+
const parsed = parseLesson(text);
|
|
270
|
+
if (!parsed.ok) continue;
|
|
271
|
+
const l = parsed.lesson;
|
|
272
|
+
rows.push({ file: `${LESSONS_DIR}/${name}`, paths: l.paths, hits: l.hits, lastHit: l.lastHit, summary: l.summary });
|
|
273
|
+
}
|
|
274
|
+
return rows.sort((a, b) => b.hits - a.hits || a.file.localeCompare(b.file));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function renderIndex(rows: IndexRow[]): string {
|
|
278
|
+
const body = rows.map(
|
|
279
|
+
(r) => `| ${r.file} | ${cell(r.paths.join(", "))} | ${r.hits} | ${r.lastHit} | ${cell(r.summary)} |`,
|
|
280
|
+
);
|
|
281
|
+
return [
|
|
282
|
+
BANNER,
|
|
283
|
+
"",
|
|
284
|
+
"# Lessons",
|
|
285
|
+
"",
|
|
286
|
+
"| lesson | paths | hits | last-hit | summary |",
|
|
287
|
+
"| --- | --- | --- | --- | --- |",
|
|
288
|
+
...body,
|
|
289
|
+
"",
|
|
290
|
+
].join("\n");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function parseIndex(text: unknown): IndexRow[] {
|
|
294
|
+
const rows: IndexRow[] = [];
|
|
295
|
+
for (const line of String(text).split("\n")) {
|
|
296
|
+
if (!line.startsWith(`| ${LESSONS_DIR}/`)) continue;
|
|
297
|
+
const cells = line
|
|
298
|
+
.slice(2, -2)
|
|
299
|
+
.replace(/\\\|/g, "\u0000")
|
|
300
|
+
.split(" | ")
|
|
301
|
+
.map((c) => c.replace(/\u0000/g, "|"));
|
|
302
|
+
if (cells.length !== 5) continue;
|
|
303
|
+
const [file, pathsCell, hits, lastHit, summary] = cells /* five cells checked above */ as [
|
|
304
|
+
string,
|
|
305
|
+
string,
|
|
306
|
+
string,
|
|
307
|
+
string,
|
|
308
|
+
string,
|
|
309
|
+
];
|
|
310
|
+
rows.push({
|
|
311
|
+
file,
|
|
312
|
+
paths: pathsCell.split(", ").filter(Boolean),
|
|
313
|
+
hits: Number(hits),
|
|
314
|
+
lastHit,
|
|
315
|
+
summary,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
return rows;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Derived from the lesson files only, so any writer rerunning it converges; temp file then rename. */
|
|
322
|
+
export function writeIndex({
|
|
323
|
+
workspaceRoot: ws,
|
|
324
|
+
pending,
|
|
325
|
+
}: {
|
|
326
|
+
workspaceRoot: string;
|
|
327
|
+
pending?: Record<string, string>;
|
|
328
|
+
}): {
|
|
329
|
+
ok: boolean;
|
|
330
|
+
path: string;
|
|
331
|
+
} {
|
|
332
|
+
const dir = lessonsDir(ws);
|
|
333
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
334
|
+
const text = renderIndex(indexRows(ws, pending));
|
|
335
|
+
const dest = path.join(dir, INDEX_NAME);
|
|
336
|
+
const tmp = `${dest}.${process.pid}.tmp`;
|
|
337
|
+
fs.writeFileSync(tmp, text);
|
|
338
|
+
fs.renameSync(tmp, dest);
|
|
339
|
+
return { ok: true, path: dest };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** `{ root, checkout, cwd }` standing in for the roots of a lone checkout `ws`. */
|
|
343
|
+
function loneRoots(ws: string): Roots {
|
|
344
|
+
const dir = canonicalPath(path.resolve(ws));
|
|
345
|
+
return { root: dir, checkout: dir, cwd: dir };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* The checkout whose lessons govern `file`: the root or the managed worktree holding it, else the caller's
|
|
350
|
+
* `roots.checkout` (an unmanaged linked worktree sits outside the root).
|
|
351
|
+
*/
|
|
352
|
+
export function holdingCheckout(file: string, roots: Roots): string {
|
|
353
|
+
const dir = managedLessonsDir(file, roots);
|
|
354
|
+
return dir ? path.dirname(path.dirname(dir)) : canonicalPath(path.resolve(roots.checkout));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Index rows whose globs match any of `files`, by hits then name. Each file is matched against the lessons of the
|
|
359
|
+
* checkout that holds it. Reads INDEX.md only. Pass `roots` (from `resolveRoots`), or `workspaceRoot` for one checkout.
|
|
360
|
+
*/
|
|
361
|
+
export function matchLessons(
|
|
362
|
+
opts: ({ roots: Roots; workspaceRoot?: undefined } | { roots?: undefined; workspaceRoot: string }) & {
|
|
363
|
+
files: string[];
|
|
364
|
+
cap?: number;
|
|
365
|
+
},
|
|
366
|
+
): IndexRow[] {
|
|
367
|
+
const { files, cap = DEFAULT_CAP } = opts;
|
|
368
|
+
const scope = opts.roots ?? loneRoots(opts.workspaceRoot);
|
|
369
|
+
const byHolder = new Map<string, string[]>();
|
|
370
|
+
for (const f of files) {
|
|
371
|
+
const abs = canonicalPath(path.resolve(scope.cwd, f));
|
|
372
|
+
const holder = holdingCheckout(abs, scope);
|
|
373
|
+
const rel = path.relative(holder, abs);
|
|
374
|
+
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) continue;
|
|
375
|
+
byHolder.set(holder, [...(byHolder.get(holder) || []), toPosix(rel)]);
|
|
376
|
+
}
|
|
377
|
+
const rows = new Map<string, IndexRow>();
|
|
378
|
+
for (const [holder, rels] of byHolder) {
|
|
379
|
+
let text: string;
|
|
380
|
+
try {
|
|
381
|
+
text = fs.readFileSync(path.join(lessonsDir(holder), INDEX_NAME), "utf8");
|
|
382
|
+
} catch {
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
for (const row of parseIndex(text)) {
|
|
386
|
+
const seen = rows.get(row.file);
|
|
387
|
+
if (row.paths.some((g) => rels.some((f) => globToRegExp(g).test(f))) && (!seen || seen.hits < row.hits)) {
|
|
388
|
+
rows.set(row.file, row);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return [...rows.values()].sort((a, b) => b.hits - a.hits || a.file.localeCompare(b.file)).slice(0, cap);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** The refusal text when `ws` is a committed repo's main checkout, else null: lessons change in a worktree. */
|
|
396
|
+
function mainCheckoutRefusal(ws: string) {
|
|
397
|
+
return mainCheckoutWriteReason(lessonsDir(ws), resolveRoots({ workspaceRoot: ws }));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function writeLesson(ws: string, slug: string, text: string) {
|
|
401
|
+
fs.mkdirSync(lessonsDir(ws), { recursive: true });
|
|
402
|
+
fs.writeFileSync(lessonPath(ws, slug), text);
|
|
403
|
+
writeIndex({ workspaceRoot: ws });
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* A Write of `.repo/lessons/{slug}.md`: fill defaults, render canonical, validate, and reindex with
|
|
408
|
+
* the pending text. The host writes the lesson: a write here first makes Claude Code refuse the Write.
|
|
409
|
+
*/
|
|
410
|
+
export function persistLesson({
|
|
411
|
+
workspaceRoot: ws,
|
|
412
|
+
filePath,
|
|
413
|
+
content,
|
|
414
|
+
today = utcDate(),
|
|
415
|
+
}: {
|
|
416
|
+
workspaceRoot: string;
|
|
417
|
+
filePath: string;
|
|
418
|
+
content: unknown;
|
|
419
|
+
today?: string;
|
|
420
|
+
}): LessonResult {
|
|
421
|
+
const refusal = mainCheckoutRefusal(ws);
|
|
422
|
+
if (refusal) return { ok: false, error: refusal };
|
|
423
|
+
const abs = path.resolve(ws, filePath);
|
|
424
|
+
const name = path.basename(abs);
|
|
425
|
+
if (path.dirname(abs) !== lessonsDir(ws) || name === INDEX_NAME || !name.endsWith(".md")) {
|
|
426
|
+
return { ok: false, error: `write lessons as ${LESSONS_DIR}/{slug}.md; ${INDEX_NAME} is generated by lessons.ts` };
|
|
427
|
+
}
|
|
428
|
+
const slug = name.slice(0, -3);
|
|
429
|
+
if (!SLUG_RE.test(slug)) return { ok: false, error: `lesson filename must be a lowercase-hyphen slug: ${name}` };
|
|
430
|
+
const parsed = parseLesson(content, { defaults: { name: slug, hits: "1", "last-hit": today, evidence: "[]" } });
|
|
431
|
+
if (!parsed.ok) return { ok: false, error: parsed.errors.join("; ") };
|
|
432
|
+
const text = renderLesson({ ...parsed.lesson, name: slug });
|
|
433
|
+
const errors = validateLesson(name, text, { files: repoFiles(ws) });
|
|
434
|
+
if (errors.length) return { ok: false, error: errors.join("; ") };
|
|
435
|
+
writeIndex({ workspaceRoot: ws, pending: { [name]: text } });
|
|
436
|
+
return { ok: true, path: abs, content: text };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Another occurrence. The same evidence twice counts once, so a retried hit converges. */
|
|
440
|
+
export function hitLesson({
|
|
441
|
+
workspaceRoot: ws,
|
|
442
|
+
slug,
|
|
443
|
+
evidence,
|
|
444
|
+
today = utcDate(),
|
|
445
|
+
}: {
|
|
446
|
+
workspaceRoot: string;
|
|
447
|
+
slug: string;
|
|
448
|
+
evidence: string;
|
|
449
|
+
today?: string;
|
|
450
|
+
}): HitResult {
|
|
451
|
+
const refusal = mainCheckoutRefusal(ws);
|
|
452
|
+
if (refusal) return { ok: false, error: refusal };
|
|
453
|
+
const file = lessonPath(ws, slug);
|
|
454
|
+
if (!fs.existsSync(file)) return { ok: false, error: `no lesson ${slug}; write ${LESSONS_DIR}/${slug}.md first` };
|
|
455
|
+
const parsed = parseLesson(fs.readFileSync(file, "utf8"));
|
|
456
|
+
if (!parsed.ok) return { ok: false, error: parsed.errors.join("; ") };
|
|
457
|
+
const l = parsed.lesson;
|
|
458
|
+
if (l.evidence.includes(evidence)) return { ok: true, path: file, hits: l.hits };
|
|
459
|
+
const next = {
|
|
460
|
+
...l,
|
|
461
|
+
hits: l.hits + 1,
|
|
462
|
+
lastHit: today,
|
|
463
|
+
evidence: [...l.evidence, evidence].slice(-MAX_EVIDENCE),
|
|
464
|
+
};
|
|
465
|
+
writeLesson(ws, slug, renderLesson(next));
|
|
466
|
+
return { ok: true, path: file, hits: next.hits };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function removeLesson({ workspaceRoot: ws, slug }: { workspaceRoot: string; slug: string }): RemoveResult {
|
|
470
|
+
const refusal = mainCheckoutRefusal(ws);
|
|
471
|
+
if (refusal) return { ok: false, error: refusal };
|
|
472
|
+
const file = lessonPath(ws, slug);
|
|
473
|
+
if (!fs.existsSync(file)) return { ok: false, error: `no lesson ${slug}` };
|
|
474
|
+
fs.rmSync(file);
|
|
475
|
+
writeIndex({ workspaceRoot: ws });
|
|
476
|
+
return { ok: true };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** One error per lesson file that breaks the format or whose `paths` match no file; empty without a lessons dir. */
|
|
480
|
+
export function lessonProblems(ws: string): string[] {
|
|
481
|
+
const dir = lessonsDir(ws);
|
|
482
|
+
if (!fs.existsSync(dir)) return [];
|
|
483
|
+
const errors: string[] = [];
|
|
484
|
+
const files = repoFiles(ws);
|
|
485
|
+
for (const name of lessonFiles(ws)) {
|
|
486
|
+
for (const e of validateLesson(name, fs.readFileSync(path.join(dir, name), "utf8"), { files })) {
|
|
487
|
+
errors.push(`${LESSONS_DIR}/${name}: ${e}`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return errors;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** The text of the lessons index for the lesson files under `ws`. */
|
|
494
|
+
export function renderLessonsIndex(ws: string): string {
|
|
495
|
+
return renderIndex(indexRows(ws));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export function checkLessons({ workspaceRoot: ws }: { workspaceRoot: string }): string[] {
|
|
499
|
+
const dir = lessonsDir(ws);
|
|
500
|
+
if (!fs.existsSync(dir)) return [];
|
|
501
|
+
const errors = lessonProblems(ws);
|
|
502
|
+
let onDisk: string | null = null;
|
|
503
|
+
try {
|
|
504
|
+
onDisk = fs.readFileSync(path.join(dir, INDEX_NAME), "utf8");
|
|
505
|
+
} catch {
|
|
506
|
+
onDisk = null;
|
|
507
|
+
}
|
|
508
|
+
if (onDisk !== renderLessonsIndex(ws)) {
|
|
509
|
+
errors.push(`${LESSONS_DIR}/${INDEX_NAME} is out of date; run bun repo lessons index inside a worktree`);
|
|
510
|
+
}
|
|
511
|
+
return errors;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function fail(msg: string) {
|
|
515
|
+
process.stderr.write(`${msg}\n`);
|
|
516
|
+
return 1;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function runLessons(argv: string[], roots: Roots): number {
|
|
520
|
+
const ws = roots.checkout;
|
|
521
|
+
const [cmd, ...rest] = argv;
|
|
522
|
+
if (cmd === "match") {
|
|
523
|
+
if (!rest.length) return fail(USAGE);
|
|
524
|
+
for (const r of matchLessons({ roots, files: rest.map((f) => path.resolve(roots.cwd, f)) })) {
|
|
525
|
+
process.stdout.write(`${r.file} (hits ${r.hits}): ${r.summary}\n`);
|
|
526
|
+
}
|
|
527
|
+
return 0;
|
|
528
|
+
}
|
|
529
|
+
if (cmd === "hit") {
|
|
530
|
+
const at = rest.indexOf("--evidence");
|
|
531
|
+
const slug = rest[0];
|
|
532
|
+
const evidence = at === -1 ? "" : rest[at + 1];
|
|
533
|
+
if (!slug || slug === "--evidence" || !evidence) return fail(USAGE);
|
|
534
|
+
const r = hitLesson({ workspaceRoot: ws, slug, evidence });
|
|
535
|
+
if (!r.ok) return fail(r.error);
|
|
536
|
+
process.stdout.write(`${LESSONS_DIR}/${slug}.md hits ${r.hits}\n`);
|
|
537
|
+
return 0;
|
|
538
|
+
}
|
|
539
|
+
if (cmd === "rm") {
|
|
540
|
+
if (!rest[0]) return fail(USAGE);
|
|
541
|
+
const r = removeLesson({ workspaceRoot: ws, slug: rest[0] });
|
|
542
|
+
return r.ok ? 0 : fail(r.error);
|
|
543
|
+
}
|
|
544
|
+
if (cmd === "index" && !rest.length) {
|
|
545
|
+
const refusal = mainCheckoutRefusal(ws);
|
|
546
|
+
if (refusal) return fail(refusal);
|
|
547
|
+
writeIndex({ workspaceRoot: ws });
|
|
548
|
+
return 0;
|
|
549
|
+
}
|
|
550
|
+
if (cmd === "check" && !rest.length) {
|
|
551
|
+
const errors = checkLessons({ workspaceRoot: ws });
|
|
552
|
+
if (!errors.length) return 0;
|
|
553
|
+
return fail(errors.join("\n"));
|
|
554
|
+
}
|
|
555
|
+
return fail(USAGE);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export function main(argv: string[], roots: () => Roots = resolveRoots): number {
|
|
559
|
+
try {
|
|
560
|
+
return runLessons(argv, roots());
|
|
561
|
+
} catch (err) {
|
|
562
|
+
return fail((err /* fs, JSON and the roots lookup throw Errors */ as Error).message);
|
|
563
|
+
}
|
|
564
|
+
}
|