@erclx/aitk 0.8.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.
Files changed (359) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/claude/.claude-plugin/plugin.json +19 -0
  4. package/claude/skills/bash-script/SKILL.md +96 -0
  5. package/claude/skills/bash-script/references/patterns.md +349 -0
  6. package/claude/skills/ci-workflow/SKILL.md +52 -0
  7. package/claude/skills/ci-workflow/references/workflows.md +74 -0
  8. package/claude/skills/claude-address-review/SKILL.md +107 -0
  9. package/claude/skills/claude-autoship/SKILL.md +137 -0
  10. package/claude/skills/claude-design-extract/SKILL.md +69 -0
  11. package/claude/skills/claude-design-propose/SKILL.md +75 -0
  12. package/claude/skills/claude-diagram/SKILL.md +126 -0
  13. package/claude/skills/claude-docs/SKILL.md +204 -0
  14. package/claude/skills/claude-feature/REQUIREMENT.md +35 -0
  15. package/claude/skills/claude-feature/SKILL.md +148 -0
  16. package/claude/skills/claude-groundwork/SKILL.md +148 -0
  17. package/claude/skills/claude-groundwork/references/folder-format.md +107 -0
  18. package/claude/skills/claude-memory-capture/SKILL.md +80 -0
  19. package/claude/skills/claude-memory-review/REQUIREMENT.md +37 -0
  20. package/claude/skills/claude-memory-review/SKILL.md +206 -0
  21. package/claude/skills/claude-orchestrate/SKILL.md +125 -0
  22. package/claude/skills/claude-pr-review/SKILL.md +150 -0
  23. package/claude/skills/claude-review/SKILL.md +136 -0
  24. package/claude/skills/claude-roadmap/SKILL.md +61 -0
  25. package/claude/skills/claude-roadmap/references/roadmap.md +52 -0
  26. package/claude/skills/claude-screencast/SKILL.md +160 -0
  27. package/claude/skills/claude-seed-sync/SKILL.md +141 -0
  28. package/claude/skills/claude-slides-draft/SKILL.md +62 -0
  29. package/claude/skills/claude-standards-audit/REQUIREMENT.md +33 -0
  30. package/claude/skills/claude-standards-audit/SKILL.md +79 -0
  31. package/claude/skills/claude-standards-audit/references/branch.md +49 -0
  32. package/claude/skills/claude-standards-audit/references/pr.md +124 -0
  33. package/claude/skills/claude-tasks/SKILL.md +123 -0
  34. package/claude/skills/claude-ui-test/SKILL.md +75 -0
  35. package/claude/skills/claude-ux-audit/SKILL.md +78 -0
  36. package/claude/skills/claude-worktree/SKILL.md +79 -0
  37. package/claude/skills/cli-script/SKILL.md +47 -0
  38. package/claude/skills/cli-script/references/template.md +43 -0
  39. package/claude/skills/create-rule/SKILL.md +68 -0
  40. package/claude/skills/create-skill/REQUIREMENT.md +30 -0
  41. package/claude/skills/create-skill/SKILL.md +24 -0
  42. package/claude/skills/create-snippet/SKILL.md +28 -0
  43. package/claude/skills/create-snippet/references/snippets.md +57 -0
  44. package/claude/skills/create-standard/SKILL.md +31 -0
  45. package/claude/skills/create-standard/references/standard.md +52 -0
  46. package/claude/skills/docs-sync/SKILL.md +87 -0
  47. package/claude/skills/git-branch/REQUIREMENT.md +38 -0
  48. package/claude/skills/git-branch/SKILL.md +60 -0
  49. package/claude/skills/git-branch/references/branch.md +49 -0
  50. package/claude/skills/git-commit/REQUIREMENT.md +33 -0
  51. package/claude/skills/git-commit/SKILL.md +51 -0
  52. package/claude/skills/git-commit/references/commit.md +63 -0
  53. package/claude/skills/git-followup/REQUIREMENT.md +38 -0
  54. package/claude/skills/git-followup/SKILL.md +35 -0
  55. package/claude/skills/git-issue/REQUIREMENT.md +38 -0
  56. package/claude/skills/git-issue/SKILL.md +69 -0
  57. package/claude/skills/git-issue/references/issue.md +84 -0
  58. package/claude/skills/git-pr/REQUIREMENT.md +37 -0
  59. package/claude/skills/git-pr/SKILL.md +119 -0
  60. package/claude/skills/git-pr/references/branch.md +49 -0
  61. package/claude/skills/git-pr/references/pr.md +124 -0
  62. package/claude/skills/git-ship/REQUIREMENT.md +34 -0
  63. package/claude/skills/git-ship/SKILL.md +39 -0
  64. package/claude/skills/git-split/REQUIREMENT.md +38 -0
  65. package/claude/skills/git-split/SKILL.md +160 -0
  66. package/claude/skills/git-split/references/branch.md +49 -0
  67. package/claude/skills/git-split/references/pr.md +124 -0
  68. package/claude/skills/git-stage/REQUIREMENT.md +36 -0
  69. package/claude/skills/git-stage/SKILL.md +74 -0
  70. package/claude/skills/git-stage/references/commit.md +63 -0
  71. package/claude/skills/git-worktree/REQUIREMENT.md +37 -0
  72. package/claude/skills/git-worktree/SKILL.md +120 -0
  73. package/claude/skills/migration-claude-md/SKILL.md +74 -0
  74. package/claude/skills/migration-context/SKILL.md +93 -0
  75. package/claude/skills/migration-standards/SKILL.md +75 -0
  76. package/claude/skills/project-commands/SKILL.md +73 -0
  77. package/claude/skills/session-resume/SKILL.md +36 -0
  78. package/claude/skills/setup-gov/SKILL.md +71 -0
  79. package/claude/skills/setup-indexes/SKILL.md +148 -0
  80. package/claude/skills/setup-init/SKILL.md +114 -0
  81. package/claude/skills/setup-plugins/SKILL.md +81 -0
  82. package/claude/skills/setup-plugins/references/plugin-catalog.md +53 -0
  83. package/claude/skills/setup-verify/SKILL.md +51 -0
  84. package/claude/skills/systematic-debugging/SKILL.md +70 -0
  85. package/claude/skills/toolkit-cli/SKILL.md +38 -0
  86. package/claude/skills/toolkit-feedback/SKILL.md +80 -0
  87. package/claude/skills/toolkit-operator/SKILL.md +45 -0
  88. package/claude/skills/toolkit-triage/SKILL.md +63 -0
  89. package/claude/skills/youtube-transcripts/SKILL.md +34 -0
  90. package/docs/agents.md +371 -0
  91. package/docs/ai-workflow.md +157 -0
  92. package/docs/index.md +20 -0
  93. package/docs/operating-model.md +106 -0
  94. package/docs/target-projects.md +183 -0
  95. package/docs/visual-design-workflow.md +136 -0
  96. package/docs/zshrc-aliases.md +65 -0
  97. package/governance/rules/claude/500-prose.md +12 -0
  98. package/governance/rules/claude/510-context.md +16 -0
  99. package/governance/rules/claude/520-wireframes.md +12 -0
  100. package/governance/rules/claude/530-requirements.md +11 -0
  101. package/governance/rules/claude/540-architecture.md +11 -0
  102. package/governance/rules/claude/550-design.md +11 -0
  103. package/governance/rules/claude/555-tasks.md +12 -0
  104. package/governance/rules/claude/560-diagrams.md +11 -0
  105. package/governance/rules/claude/570-skill.md +18 -0
  106. package/governance/rules/claude/580-readme.md +11 -0
  107. package/governance/rules/claude/590-rule-authoring.md +12 -0
  108. package/governance/rules/core/000-constitution.md +30 -0
  109. package/governance/rules/core/010-testing.md +35 -0
  110. package/governance/rules/core/020-concurrency.md +22 -0
  111. package/governance/rules/core/030-error-handling.md +31 -0
  112. package/governance/rules/core/040-performance.md +20 -0
  113. package/governance/rules/core/050-logging.md +20 -0
  114. package/governance/rules/core/060-naming.md +19 -0
  115. package/governance/rules/core/070-planning.md +13 -0
  116. package/governance/rules/core/080-config-comments.md +22 -0
  117. package/governance/rules/framework/200-react.md +51 -0
  118. package/governance/rules/framework/210-astro.md +41 -0
  119. package/governance/rules/framework/220-fastapi.md +43 -0
  120. package/governance/rules/framework/230-nextjs.md +48 -0
  121. package/governance/rules/framework/250-tailwind.md +32 -0
  122. package/governance/rules/framework/260-shadcn.md +34 -0
  123. package/governance/rules/lang/100-typescript.md +40 -0
  124. package/governance/rules/lang/110-python.md +42 -0
  125. package/governance/rules/lib/300-testing-ts.md +38 -0
  126. package/governance/rules/lib/310-zod.md +25 -0
  127. package/governance/rules/lib/320-tanstack-query.md +32 -0
  128. package/governance/rules/lib/330-testing-py.md +44 -0
  129. package/governance/rules/lib/340-pydantic.md +38 -0
  130. package/governance/rules/lib/350-security-web.md +31 -0
  131. package/governance/rules/ui/400-ui.md +34 -0
  132. package/governance/rules/ui/410-a11y.md +44 -0
  133. package/governance/rules/ui/420-forms.md +34 -0
  134. package/governance/rules/ui/430-ux-completeness.md +62 -0
  135. package/governance/stacks/astro.toml +2 -0
  136. package/governance/stacks/base.toml +2 -0
  137. package/governance/stacks/node.toml +2 -0
  138. package/governance/stacks/python-fastapi.toml +2 -0
  139. package/governance/stacks/python.toml +2 -0
  140. package/governance/stacks/react.toml +2 -0
  141. package/package.json +67 -0
  142. package/scripts/config.sh +11 -0
  143. package/scripts/core/bootstrap.sh +81 -0
  144. package/scripts/core/check-plugin-boundary.sh +45 -0
  145. package/scripts/core/check-skill-paths.sh +25 -0
  146. package/scripts/core/clean.sh +36 -0
  147. package/scripts/core/install-check.sh +93 -0
  148. package/scripts/core/regen-claude-copies.sh +26 -0
  149. package/scripts/core/regen-indexes.sh +10 -0
  150. package/scripts/core/regen-skill-references.sh +27 -0
  151. package/scripts/core/snapshot.sh +132 -0
  152. package/scripts/core/update.sh +35 -0
  153. package/scripts/core/verify.sh +201 -0
  154. package/scripts/docs/list.sh +129 -0
  155. package/scripts/gov/list.sh +234 -0
  156. package/scripts/lib/frontmatter.sh +30 -0
  157. package/scripts/lib/gov.sh +14 -0
  158. package/scripts/lib/sandbox-fixtures.sh +109 -0
  159. package/scripts/lib/sandbox-git.sh +73 -0
  160. package/scripts/lib/tooling.sh +24 -0
  161. package/scripts/lib/ui.sh +173 -0
  162. package/scripts/lib/worktree.sh +20 -0
  163. package/scripts/manage-sandbox.sh +453 -0
  164. package/scripts/snippets/create.sh +156 -0
  165. package/scripts/standards/list.sh +91 -0
  166. package/scripts/tooling/create.sh +109 -0
  167. package/scripts/tooling/ref.sh +141 -0
  168. package/scripts/tooling/verify.sh +179 -0
  169. package/snippets/claude/decision-memo.md +39 -0
  170. package/snippets/claude/feature-recap.md +19 -0
  171. package/snippets/claude/figma-steps.md +18 -0
  172. package/snippets/claude/orchestrator-resume.md +23 -0
  173. package/snippets/claude/orchestrator-sweep.md +10 -0
  174. package/snippets/compact-summary.md +5 -0
  175. package/snippets/create-snippet.md +7 -0
  176. package/snippets/decision-help.md +6 -0
  177. package/snippets/meta-prompt.md +14 -0
  178. package/snippets/research-prompt.md +7 -0
  179. package/snippets/session-notes.md +11 -0
  180. package/snippets/snippets.toml +6 -0
  181. package/snippets/step-by-step.md +10 -0
  182. package/snippets/web-research.md +21 -0
  183. package/src/claude/gitignore.ts +51 -0
  184. package/src/claude/seeds-list.ts +47 -0
  185. package/src/claude/seeds.ts +124 -0
  186. package/src/claude/settings.ts +151 -0
  187. package/src/cli-run.ts +43 -0
  188. package/src/cli.ts +111 -0
  189. package/src/commands/claude.ts +349 -0
  190. package/src/commands/design.ts +39 -0
  191. package/src/commands/docs.ts +59 -0
  192. package/src/commands/feedback-format.ts +23 -0
  193. package/src/commands/feedback.ts +103 -0
  194. package/src/commands/gov.ts +268 -0
  195. package/src/commands/indexes.ts +182 -0
  196. package/src/commands/init.ts +117 -0
  197. package/src/commands/pass-through.ts +39 -0
  198. package/src/commands/sandbox.ts +315 -0
  199. package/src/commands/slides.ts +106 -0
  200. package/src/commands/snippets.ts +236 -0
  201. package/src/commands/standards.ts +89 -0
  202. package/src/commands/sync.ts +211 -0
  203. package/src/commands/tasks.ts +185 -0
  204. package/src/commands/tooling.ts +432 -0
  205. package/src/commands/transcripts.ts +48 -0
  206. package/src/commands/wiki.ts +101 -0
  207. package/src/copy.ts +30 -0
  208. package/src/design/parse.ts +79 -0
  209. package/src/design/render.ts +170 -0
  210. package/src/docs/read.ts +59 -0
  211. package/src/exec.ts +17 -0
  212. package/src/frontmatter.ts +13 -0
  213. package/src/github-format.ts +13 -0
  214. package/src/github.ts +20 -0
  215. package/src/gov/adapter.ts +73 -0
  216. package/src/gov/install.ts +103 -0
  217. package/src/gov/payload.ts +39 -0
  218. package/src/gov/stacks.ts +124 -0
  219. package/src/indexes/frontmatter.ts +46 -0
  220. package/src/indexes/regen.ts +84 -0
  221. package/src/indexes/render.ts +181 -0
  222. package/src/indexes/walk.ts +111 -0
  223. package/src/init/flags.ts +66 -0
  224. package/src/init/plan.ts +128 -0
  225. package/src/init/run.ts +46 -0
  226. package/src/init/steps.ts +91 -0
  227. package/src/sandbox/coverage.ts +115 -0
  228. package/src/sandbox/expect.ts +412 -0
  229. package/src/slides/layouts.ts +391 -0
  230. package/src/slides/open.ts +18 -0
  231. package/src/slides/parse.ts +84 -0
  232. package/src/slides/render.ts +88 -0
  233. package/src/slides/styles.ts +44 -0
  234. package/src/snippets/adapter.ts +34 -0
  235. package/src/snippets/categories.ts +59 -0
  236. package/src/snippets/install.ts +125 -0
  237. package/src/snippets/list.ts +32 -0
  238. package/src/snippets/presets.ts +50 -0
  239. package/src/standards/adapter.ts +51 -0
  240. package/src/standards/index-refresh.ts +44 -0
  241. package/src/standards/install.ts +52 -0
  242. package/src/sync/check.ts +269 -0
  243. package/src/sync/engine.ts +416 -0
  244. package/src/sync/git.ts +204 -0
  245. package/src/sync/stamp.ts +202 -0
  246. package/src/sync/target.ts +79 -0
  247. package/src/sync/workflow.ts +201 -0
  248. package/src/target.ts +43 -0
  249. package/src/tasks/archive.ts +304 -0
  250. package/src/tooling/gitignore.ts +122 -0
  251. package/src/tooling/inject.ts +214 -0
  252. package/src/tooling/list.ts +39 -0
  253. package/src/tooling/manifest.ts +178 -0
  254. package/src/tooling/package.ts +166 -0
  255. package/src/tooling/scan.ts +186 -0
  256. package/src/transcripts/fetch.ts +156 -0
  257. package/src/transcripts/metadata.ts +54 -0
  258. package/src/transcripts/vtt.ts +114 -0
  259. package/src/ui.ts +187 -0
  260. package/src/wiki/init.ts +85 -0
  261. package/standards/architecture.md +41 -0
  262. package/standards/bundled/branch.md +49 -0
  263. package/standards/bundled/commit.md +63 -0
  264. package/standards/bundled/issue.md +84 -0
  265. package/standards/bundled/pr.md +124 -0
  266. package/standards/bundled/roadmap.md +52 -0
  267. package/standards/bundled/snippets.md +57 -0
  268. package/standards/bundled/standard.md +52 -0
  269. package/standards/context.md +132 -0
  270. package/standards/design.md +31 -0
  271. package/standards/diagrams.md +55 -0
  272. package/standards/index.md +21 -0
  273. package/standards/prose.md +122 -0
  274. package/standards/readme.md +163 -0
  275. package/standards/requirements.md +61 -0
  276. package/standards/rule.md +79 -0
  277. package/standards/skill.md +257 -0
  278. package/standards/tasks.md +140 -0
  279. package/standards/versioning.md +47 -0
  280. package/standards/wireframes.md +67 -0
  281. package/tooling/astro/configs/astro.config.mjs +17 -0
  282. package/tooling/astro/configs/eslint.config.js +79 -0
  283. package/tooling/astro/configs/playwright.config.ts +25 -0
  284. package/tooling/astro/configs/tsconfig.json +12 -0
  285. package/tooling/astro/configs/vitest.config.ts +22 -0
  286. package/tooling/astro/manifest.toml +32 -0
  287. package/tooling/astro/reference.md +60 -0
  288. package/tooling/base/configs/.editorconfig +5 -0
  289. package/tooling/base/configs/.github/pull_request_template.md +18 -0
  290. package/tooling/base/configs/.github/workflows/verify.yml +35 -0
  291. package/tooling/base/configs/.husky/commit-msg +1 -0
  292. package/tooling/base/configs/.husky/post-merge +61 -0
  293. package/tooling/base/configs/.husky/post-rewrite +21 -0
  294. package/tooling/base/configs/.husky/pre-commit +1 -0
  295. package/tooling/base/configs/.husky/pre-push +1 -0
  296. package/tooling/base/configs/.prettierrc +12 -0
  297. package/tooling/base/configs/.shellcheckrc +1 -0
  298. package/tooling/base/configs/.vscode/extensions.json +9 -0
  299. package/tooling/base/configs/.vscode/settings.json +3 -0
  300. package/tooling/base/configs/commitlint.config.js +11 -0
  301. package/tooling/base/configs/scripts/clean.sh +45 -0
  302. package/tooling/base/configs/scripts/update.sh +49 -0
  303. package/tooling/base/configs/scripts/verify.sh +64 -0
  304. package/tooling/base/manifest.toml +32 -0
  305. package/tooling/base/reference.md +114 -0
  306. package/tooling/base/seeds/.claude/context/ci.md +27 -0
  307. package/tooling/base/seeds/.claude/context/development.md +32 -0
  308. package/tooling/base/seeds/.cspell/project-terms.txt +0 -0
  309. package/tooling/base/seeds/.cspell/tech-stack.txt +19 -0
  310. package/tooling/base/seeds/.lintstagedrc +8 -0
  311. package/tooling/base/seeds/.prettierignore +0 -0
  312. package/tooling/base/seeds/cspell.json +20 -0
  313. package/tooling/claude/manifest.toml +11 -0
  314. package/tooling/claude/reference.md +62 -0
  315. package/tooling/claude/seeds/.claude/ARCHITECTURE.md +13 -0
  316. package/tooling/claude/seeds/.claude/DESIGN.md +62 -0
  317. package/tooling/claude/seeds/.claude/REQUIREMENTS.md +18 -0
  318. package/tooling/claude/seeds/.claude/context/index.md +8 -0
  319. package/tooling/claude/seeds/.claude/hooks/index-reminder.sh +43 -0
  320. package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +45 -0
  321. package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +61 -0
  322. package/tooling/claude/seeds/.claude/hooks/tasks-index.sh +60 -0
  323. package/tooling/claude/seeds/.claude/settings.json +39 -0
  324. package/tooling/claude/seeds/.claude/tasks/index.md +8 -0
  325. package/tooling/claude/seeds/.claude/wireframes/index.md +8 -0
  326. package/tooling/claude/seeds/CLAUDE.md +87 -0
  327. package/tooling/claude/user/settings.template.json +10 -0
  328. package/tooling/claude/user/statusline-command.sh +53 -0
  329. package/tooling/python/configs/.coveragerc +14 -0
  330. package/tooling/python/configs/.python-version +1 -0
  331. package/tooling/python/configs/mypy.ini +6 -0
  332. package/tooling/python/configs/pytest.ini +4 -0
  333. package/tooling/python/configs/ruff.toml +15 -0
  334. package/tooling/python/configs/scripts/verify.sh +77 -0
  335. package/tooling/python/manifest.toml +16 -0
  336. package/tooling/python/reference.md +66 -0
  337. package/tooling/python/seeds/.cspell/tech-stack.txt +19 -0
  338. package/tooling/python/seeds/tests/test_smoke.py +2 -0
  339. package/tooling/vite-react/configs/playwright.config.ts +25 -0
  340. package/tooling/vite-react/configs/tsconfig.json +35 -0
  341. package/tooling/vite-react/configs/vite.config.ts +14 -0
  342. package/tooling/vite-react/configs/vitest.config.ts +26 -0
  343. package/tooling/vite-react/manifest.toml +21 -0
  344. package/tooling/vite-react/reference.md +55 -0
  345. package/tooling/vite-react/seeds/.cspell/project-terms.txt +1 -0
  346. package/tooling/vite-react/seeds/.cspell/tech-stack.txt +1 -0
  347. package/tooling/web/configs/.github/workflows/verify.yml +134 -0
  348. package/tooling/web/configs/.vscode/extensions.json +13 -0
  349. package/tooling/web/configs/.vscode/settings.json +10 -0
  350. package/tooling/web/configs/e2e/home.spec.ts +6 -0
  351. package/tooling/web/configs/e2e/screenshot.ts +41 -0
  352. package/tooling/web/configs/eslint.config.js +82 -0
  353. package/tooling/web/configs/scripts/screenshot.sh +28 -0
  354. package/tooling/web/configs/scripts/verify.sh +80 -0
  355. package/tooling/web/configs/src/test/setup.ts +8 -0
  356. package/tooling/web/manifest.toml +57 -0
  357. package/tooling/web/reference.md +95 -0
  358. package/tooling/web/seeds/.cspell/tech-stack.txt +18 -0
  359. package/tsconfig.json +14 -0
@@ -0,0 +1,416 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { rm } from 'node:fs/promises'
3
+ import { isAbsolute, relative, resolve } from 'node:path'
4
+ import { copyPreservingMode } from '@/copy'
5
+ import {
6
+ type DomainHashes,
7
+ hashFile,
8
+ readStamp,
9
+ type StampSource,
10
+ stampedHashes,
11
+ toStampKey,
12
+ writeStamp,
13
+ } from '@/sync/stamp'
14
+ import { isDirectory } from '@/target'
15
+ import {
16
+ intro,
17
+ isNonInteractive,
18
+ logAdd,
19
+ logError,
20
+ logInfo,
21
+ logStep,
22
+ logWarn,
23
+ outro,
24
+ select,
25
+ } from '@/ui'
26
+
27
+ const GREEN = '\x1b[0;32m'
28
+ const NC = '\x1b[0m'
29
+
30
+ /**
31
+ * One installed file in three path flavours: absolute, relative to the
32
+ * domain's installed root, and relative to the target. Adapters match on
33
+ * `relToRoot` and every log line prints `rel`.
34
+ */
35
+ export interface InstalledFile {
36
+ readonly path: string
37
+ readonly relToRoot: string
38
+ readonly rel: string
39
+ }
40
+
41
+ export interface RetiredSurface {
42
+ readonly path: string
43
+ readonly rel: string
44
+ readonly notice: string
45
+ }
46
+
47
+ export type SyncChange =
48
+ | {
49
+ readonly kind: 'copy'
50
+ readonly source: string
51
+ readonly dest: string
52
+ readonly rel: string
53
+ }
54
+ | { readonly kind: 'delete'; readonly dest: string; readonly rel: string }
55
+
56
+ /**
57
+ * How an installed file compares to its toolkit source, and when it differs,
58
+ * who moved it. `stale` and `customized` need the stamp to tell apart, so
59
+ * `drifted` stays the verdict for a difference no stamp covers.
60
+ *
61
+ * `orphaned` and `stranded` both mean the walk found no source, and they are
62
+ * separate because they need opposite treatment. A project-authored file is
63
+ * orphaned and stays that way forever. A stamped file the toolkit no longer
64
+ * installs to is stranded, which is a relocation waiting on a decision.
65
+ */
66
+ export type EntryState =
67
+ | 'matching'
68
+ | 'stale'
69
+ | 'customized'
70
+ | 'drifted'
71
+ | 'orphaned'
72
+ | 'stranded'
73
+
74
+ export interface ScanEntry {
75
+ readonly state: EntryState
76
+ readonly rel: string
77
+ }
78
+
79
+ export interface SyncPlan {
80
+ readonly entries: readonly ScanEntry[]
81
+ readonly retired: readonly RetiredSurface[]
82
+ readonly changes: readonly SyncChange[]
83
+ }
84
+
85
+ /**
86
+ * What a headless run does once the plan has changes. Domains whose files the
87
+ * toolkit owns apply them. Domains whose files a project is expected to edit
88
+ * refuse, because an unattended overwrite of a customized file is data loss
89
+ * with no prompt in front of it.
90
+ */
91
+ export type NonInteractivePolicy =
92
+ | { readonly kind: 'apply' }
93
+ | {
94
+ readonly kind: 'refuse'
95
+ readonly message: string
96
+ readonly hint: string
97
+ }
98
+
99
+ /**
100
+ * The two holes every domain sync leaves open: where a destination file's
101
+ * source lives, and what counts as a change beyond a plain content diff.
102
+ * Everything else in a sync is identical across gov, snippets, and standards,
103
+ * so it lives in the engine.
104
+ *
105
+ * The optional members exist because one adapter needed each of them. Leaving
106
+ * all three unset reproduces the behavior gov and snippets already had.
107
+ */
108
+ export interface SyncAdapter {
109
+ readonly banner: string
110
+ /** Rendered as `Scanning <label>`. */
111
+ readonly label: string
112
+ readonly missingMessage: string
113
+ /** Noun for the completion count, e.g. `changes`. */
114
+ readonly unit: string
115
+ installedRoot(target: string): string
116
+ locateSource(file: InstalledFile): string | undefined
117
+ /** Surfaces the file walk cannot see, such as a retired doc to delete. */
118
+ collectRetired?(target: string): RetiredSurface[]
119
+ /** Dropped from the walk, so neither matching nor orphaned. */
120
+ isExcluded?(file: InstalledFile): boolean
121
+ /** Defaults to applying. */
122
+ readonly nonInteractive?: NonInteractivePolicy
123
+ /** Runs on a completed sync, including one with no changes. */
124
+ onComplete?(target: string): Promise<void>
125
+ /** Where this domain's hashes are stamped. Unset domains go unstamped. */
126
+ readonly stamp?: StampSource
127
+ }
128
+
129
+ /**
130
+ * Lists installed markdown, dotfiles included. `Bun.Glob` skips entries
131
+ * beginning with a dot unless `dot` is set, and every domain installs under
132
+ * `.claude/`, so a nested dot-directory would silently drop out of the walk.
133
+ */
134
+ export function listInstalled(root: string, target: string): InstalledFile[] {
135
+ if (!existsSync(root)) return []
136
+
137
+ return [
138
+ ...new Bun.Glob('**/*.md').scanSync({
139
+ cwd: root,
140
+ onlyFiles: true,
141
+ dot: true,
142
+ }),
143
+ ]
144
+ .sort()
145
+ .map((relToRoot) => {
146
+ const path = resolve(root, relToRoot)
147
+ return { path, relToRoot, rel: relative(target, path) }
148
+ })
149
+ }
150
+
151
+ /**
152
+ * Classifies every installed file against its source without writing anything.
153
+ * A file with no source is left alone rather than deleted, which is what keeps
154
+ * project-authored rules alive across a sync.
155
+ */
156
+ export function planSync(adapter: SyncAdapter, target: string): SyncPlan {
157
+ const entries: ScanEntry[] = []
158
+ const changes: SyncChange[] = []
159
+
160
+ const hashes = stampedHashes(readStamp(target), adapter.stamp?.domain)
161
+ const walked = new Set<string>()
162
+
163
+ for (const file of listInstalled(adapter.installedRoot(target), target)) {
164
+ if (adapter.isExcluded?.(file) === true) continue
165
+ walked.add(toStampKey(file.rel))
166
+
167
+ const source = adapter.locateSource(file)
168
+
169
+ if (source === undefined || !existsSync(source)) {
170
+ entries.push({ state: 'orphaned', rel: file.rel })
171
+ continue
172
+ }
173
+
174
+ if (sameContent(source, file.path)) {
175
+ entries.push({ state: 'matching', rel: file.rel })
176
+ continue
177
+ }
178
+
179
+ entries.push({ state: attribute(hashes, file), rel: file.rel })
180
+ changes.push({
181
+ kind: 'copy',
182
+ source,
183
+ dest: file.path,
184
+ rel: file.rel,
185
+ })
186
+ }
187
+
188
+ entries.push(...strandedByRelocation(target, hashes, walked))
189
+
190
+ const retired = adapter.collectRetired?.(target) ?? []
191
+ for (const surface of retired) {
192
+ changes.push({ kind: 'delete', dest: surface.path, rel: surface.rel })
193
+ }
194
+
195
+ return { entries, retired, changes }
196
+ }
197
+
198
+ export async function applyChanges(
199
+ changes: readonly SyncChange[],
200
+ ): Promise<void> {
201
+ logStep('Applying changes')
202
+
203
+ for (const change of changes) {
204
+ if (change.kind === 'copy') {
205
+ await copyPreservingMode(change.source, change.dest)
206
+ logAdd(change.rel)
207
+ continue
208
+ }
209
+
210
+ await rm(change.dest, { force: true })
211
+ logWarn(`removed ${change.rel}`)
212
+ }
213
+ }
214
+
215
+ export interface SyncRunOptions {
216
+ /** Path the sync refuses to run against, normally the toolkit root. */
217
+ readonly protectedRoot: string
218
+ }
219
+
220
+ /**
221
+ * Runs one domain sync end to end and returns the process exit code. Callers
222
+ * register a command, build an adapter, and hand both to this function.
223
+ */
224
+ export async function runDomainSync(
225
+ adapter: SyncAdapter,
226
+ target: string,
227
+ options: SyncRunOptions,
228
+ ): Promise<number> {
229
+ intro(adapter.banner)
230
+
231
+ const resolved = resolve(target)
232
+
233
+ if (!isDirectory(resolved)) {
234
+ logError(`Target directory not found: ${target}`)
235
+ outro()
236
+ return 1
237
+ }
238
+
239
+ if (resolved === options.protectedRoot) {
240
+ logError(
241
+ 'Cannot run against toolkit root. Files here are the source of truth.',
242
+ )
243
+ outro()
244
+ return 1
245
+ }
246
+
247
+ const plan = planSync(adapter, resolved)
248
+
249
+ if (
250
+ !existsSync(adapter.installedRoot(resolved)) &&
251
+ plan.retired.length === 0
252
+ ) {
253
+ logWarn(adapter.missingMessage)
254
+ outro()
255
+ return 0
256
+ }
257
+
258
+ report(adapter, plan)
259
+
260
+ const count = plan.changes.length
261
+ if (count === 0) {
262
+ await adapter.onComplete?.(resolved)
263
+ await recordStamp(adapter, resolved, new Date())
264
+ outro()
265
+ process.stderr.write(`${GREEN}✓ Everything up to date${NC}\n`)
266
+ return 0
267
+ }
268
+
269
+ const policy = adapter.nonInteractive ?? { kind: 'apply' }
270
+ if (
271
+ policy.kind === 'refuse' &&
272
+ isNonInteractive() &&
273
+ hasUnattributedDrift(plan)
274
+ ) {
275
+ logWarn(policy.message)
276
+ logInfo(policy.hint)
277
+ outro()
278
+ return 0
279
+ }
280
+
281
+ const shouldApply = await select({
282
+ message: `Apply ${count} changes?`,
283
+ options: [
284
+ { value: true, label: 'Apply all' },
285
+ { value: false, label: 'Cancel' },
286
+ ],
287
+ nonInteractiveDefault: true,
288
+ })
289
+
290
+ if (!shouldApply) {
291
+ logWarn('Sync cancelled')
292
+ outro()
293
+ return 0
294
+ }
295
+
296
+ await applyChanges(plan.changes)
297
+ await adapter.onComplete?.(resolved)
298
+ await recordStamp(adapter, resolved, new Date())
299
+
300
+ outro()
301
+ process.stderr.write(
302
+ `${GREEN}✓ Sync complete${NC} \x1b[0;90m(${count} ${adapter.unit})${NC}\n`,
303
+ )
304
+ return 0
305
+ }
306
+
307
+ /**
308
+ * Reports in walk order rather than grouped by state, so a long rule tree
309
+ * reads as a directory listing with drift called out in place.
310
+ */
311
+ function report(adapter: SyncAdapter, plan: SyncPlan): void {
312
+ logStep(`Scanning ${adapter.label}`)
313
+
314
+ for (const entry of plan.entries) {
315
+ if (entry.state === 'matching') logInfo(entry.rel)
316
+ else if (entry.state === 'drifted') logWarn(entry.rel)
317
+ else if (entry.state === 'stale') logWarn(`${entry.rel} (toolkit updated)`)
318
+ else if (entry.state === 'customized')
319
+ logWarn(`${entry.rel} (locally customized)`)
320
+ else if (entry.state === 'stranded')
321
+ logWarn(`${entry.rel} (installed here by an older toolkit, now moved)`)
322
+ else logWarn(`${entry.rel} (not in toolkit source, skipping)`)
323
+ }
324
+
325
+ for (const surface of plan.retired) {
326
+ logWarn(surface.notice)
327
+ }
328
+ }
329
+
330
+ function sameContent(left: string, right: string): boolean {
331
+ return readFileSync(left).equals(readFileSync(right))
332
+ }
333
+
334
+ /**
335
+ * Splits a difference by cause. Matching the stamp means the file is untouched
336
+ * since install and the toolkit is what moved, so the update is mechanical.
337
+ * Anything else is a local edit, and an uncovered file stays unattributed.
338
+ */
339
+ function attribute(hashes: DomainHashes, file: InstalledFile): EntryState {
340
+ const stamped = hashes[toStampKey(file.rel)]
341
+ if (stamped === undefined) return 'drifted'
342
+
343
+ return stamped === hashFile(file.path) ? 'stale' : 'customized'
344
+ }
345
+
346
+ /**
347
+ * Stamped paths the walk never reached, because the toolkit installs to a root
348
+ * it no longer uses. Reporting them is what makes a relocation visible instead
349
+ * of silent. They are left alone, and queue no change.
350
+ *
351
+ * A key escaping the target is dropped rather than reported, so a hand-edited
352
+ * stamp cannot make the report name paths outside the project.
353
+ */
354
+ function strandedByRelocation(
355
+ target: string,
356
+ hashes: DomainHashes,
357
+ walked: ReadonlySet<string>,
358
+ ): ScanEntry[] {
359
+ const entries: ScanEntry[] = []
360
+
361
+ for (const key of Object.keys(hashes).sort()) {
362
+ if (walked.has(key)) continue
363
+
364
+ const path = resolve(target, key)
365
+ if (!isInside(target, path) || !existsSync(path)) continue
366
+
367
+ entries.push({ state: 'stranded', rel: relative(target, path) })
368
+ }
369
+
370
+ return entries
371
+ }
372
+
373
+ function isInside(target: string, path: string): boolean {
374
+ const rel = relative(target, path)
375
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)
376
+ }
377
+
378
+ /**
379
+ * Drift a refusing domain must not touch unattended. A file proven to match
380
+ * what was installed carries no local edit to lose, so it does not count.
381
+ */
382
+ function hasUnattributedDrift(plan: SyncPlan): boolean {
383
+ return plan.entries.some(
384
+ (entry) => entry.state === 'customized' || entry.state === 'drifted',
385
+ )
386
+ }
387
+
388
+ /**
389
+ * Records what the toolkit placed, after the copies land, so a partial apply
390
+ * that throws leaves the previous stamp rather than a claim the target does not
391
+ * meet. Files with no source are project-authored and stay out.
392
+ *
393
+ * Reads the installed tree rather than the caller's file list, so a partial
394
+ * install still stamps the domain's whole installed set.
395
+ */
396
+ export async function recordStamp(
397
+ adapter: SyncAdapter,
398
+ target: string,
399
+ now: Date,
400
+ ): Promise<void> {
401
+ const stampSource = adapter.stamp
402
+ if (stampSource === undefined) return
403
+
404
+ const hashes: Record<string, string> = {}
405
+
406
+ for (const file of listInstalled(adapter.installedRoot(target), target)) {
407
+ if (adapter.isExcluded?.(file) === true) continue
408
+
409
+ const source = adapter.locateSource(file)
410
+ if (source === undefined || !existsSync(source)) continue
411
+
412
+ hashes[toStampKey(file.rel)] = hashFile(file.path)
413
+ }
414
+
415
+ await writeStamp(target, stampSource, hashes, now)
416
+ }
@@ -0,0 +1,204 @@
1
+ import { basename } from 'node:path'
2
+ import { execa } from 'execa'
3
+ import type { SyncDomain } from '@/sync/target'
4
+ import { pipeOutput } from '@/ui'
5
+
6
+ const FILE_LIST_MAX = 3
7
+
8
+ export type DomainVerb = 'Add' | 'Remove' | 'Update'
9
+
10
+ export interface DomainChange {
11
+ readonly domain: SyncDomain
12
+ readonly verb: DomainVerb
13
+ readonly names: readonly string[]
14
+ readonly paths: readonly string[]
15
+ }
16
+
17
+ export class GitCommandError extends Error {}
18
+
19
+ /**
20
+ * Pulls the path out of each porcelain line by taking the last whitespace
21
+ * separated field, which is what the bash `awk '{print $NF}'` did. A rename
22
+ * reports `old -> new` and so yields the destination, and a sync never
23
+ * produces one because the workflow refuses a dirty tree before it starts.
24
+ */
25
+ export function changedPaths(status: string): string[] {
26
+ const paths: string[] = []
27
+
28
+ for (const line of status.split('\n')) {
29
+ const trimmed = line.trim()
30
+ if (trimmed === '') continue
31
+ const fields = trimmed.split(/\s+/)
32
+ paths.push(fields[fields.length - 1])
33
+ }
34
+
35
+ return paths
36
+ }
37
+
38
+ export function changedNames(status: string): string[] {
39
+ const names = changedPaths(status).map((path) => basename(path))
40
+ return [...new Set(names)].sort()
41
+ }
42
+
43
+ /**
44
+ * Classifies a domain's porcelain output as one verb for the pull request body.
45
+ * Only an all-untracked change reads as `Add` and only an all-deleted one as
46
+ * `Remove`, so any mix falls through to `Update`.
47
+ */
48
+ export function classifyStatus(status: string): DomainVerb {
49
+ let hasModify = false
50
+ let hasNew = false
51
+ let hasDelete = false
52
+
53
+ for (const line of status.split('\n')) {
54
+ if (line === '') continue
55
+ const code = line.slice(0, 2)
56
+
57
+ if (code === '??') hasNew = true
58
+ else if (code === ' D' || code === 'D ' || code === 'DD') hasDelete = true
59
+ else hasModify = true
60
+ }
61
+
62
+ if (!hasModify && !hasDelete && hasNew) return 'Add'
63
+ if (!hasModify && !hasNew && hasDelete) return 'Remove'
64
+ return 'Update'
65
+ }
66
+
67
+ export function formatFileList(files: readonly string[]): string {
68
+ if (files.length === 0) return ''
69
+ if (files.length <= FILE_LIST_MAX) return files.join(', ')
70
+
71
+ const head = files.slice(0, FILE_LIST_MAX).join(', ')
72
+ return `${head}, and ${files.length - FILE_LIST_MAX} more`
73
+ }
74
+
75
+ /**
76
+ * Names the branch at minute resolution, so two syncs inside the same minute
77
+ * collide by design and the collision check turns the second one away.
78
+ */
79
+ export function syncBranchName(now: Date): string {
80
+ const pad = (value: number): string => String(value).padStart(2, '0')
81
+ const day = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
82
+ const minute = `${pad(now.getHours())}${pad(now.getMinutes())}`
83
+
84
+ return `chore/aitk-sync-${day}-${minute}`
85
+ }
86
+
87
+ export function commitMessage(domains: readonly SyncDomain[]): string {
88
+ return `chore(sync): update ${domains.join(', ')} from toolkit`
89
+ }
90
+
91
+ export function pullRequestBody(changes: readonly DomainChange[]): string {
92
+ const domains = changes.map((change) => change.domain).join(', ')
93
+ const lines = [
94
+ '## Summary',
95
+ '',
96
+ `Sync ${domains} from toolkit.`,
97
+ '',
98
+ '## Key Changes',
99
+ '',
100
+ ]
101
+
102
+ for (const change of changes) {
103
+ lines.push(
104
+ `- ${change.verb} \`${change.domain}/\` ${formatFileList(change.names)}.`,
105
+ )
106
+ }
107
+
108
+ return lines.join('\n')
109
+ }
110
+
111
+ /**
112
+ * The seam every git mutation goes through, so the workflow can be driven by a
113
+ * fake in tests without a repository behind it. Reads swallow failure the way
114
+ * the bash `2>/dev/null || true` did. Mutations throw, because a failed commit
115
+ * or push must not be reported as a completed sync.
116
+ */
117
+ export interface GitRunner {
118
+ status(paths: readonly string[]): Promise<string>
119
+ currentBranch(): Promise<string>
120
+ branchExists(name: string): Promise<boolean>
121
+ remoteBranchExists(name: string): Promise<boolean>
122
+ createBranch(name: string): Promise<void>
123
+ stage(paths: readonly string[]): Promise<void>
124
+ commit(message: string): Promise<void>
125
+ push(branch: string): Promise<void>
126
+ }
127
+
128
+ export interface PullRequestOpener {
129
+ create(title: string, body: string): Promise<string>
130
+ }
131
+
132
+ export function createGitRunner(target: string): GitRunner {
133
+ return {
134
+ status: (paths) => read(target, ['status', '--short', '--', ...paths]),
135
+ currentBranch: () => read(target, ['symbolic-ref', '--short', 'HEAD']),
136
+ branchExists: async (name) =>
137
+ (await read(target, ['branch', '--list', name])) !== '',
138
+ remoteBranchExists: async (name) =>
139
+ (await read(target, ['ls-remote', '--heads', 'origin', name])) !== '',
140
+ createBranch: async (name) => {
141
+ await mutate(target, ['checkout', '-b', name])
142
+ },
143
+ stage: async (paths) => {
144
+ await mutate(target, ['add', '--', ...paths])
145
+ },
146
+ commit: async (message) => {
147
+ await mutate(target, ['commit', '-m', message])
148
+ },
149
+ push: async (branch) => {
150
+ await mutate(target, ['push', '-u', 'origin', branch])
151
+ },
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Passes the body on stdin rather than through a temp file. The bash wrote one
157
+ * with `mktemp` and removed it afterwards, which left the file behind whenever
158
+ * `gh` failed.
159
+ */
160
+ export function createPullRequestOpener(target: string): PullRequestOpener {
161
+ return {
162
+ create: async (title, body) => {
163
+ const result = await execa(
164
+ 'gh',
165
+ ['pr', 'create', '--title', title, '--body-file', '-'],
166
+ { cwd: target, input: body, reject: false },
167
+ )
168
+
169
+ if (result.exitCode !== 0) {
170
+ throw new GitCommandError(
171
+ `gh pr create failed: ${result.stderr || result.stdout}`,
172
+ )
173
+ }
174
+
175
+ return result.stdout.trim()
176
+ },
177
+ }
178
+ }
179
+
180
+ export async function hasGh(): Promise<boolean> {
181
+ const result = await execa('command', ['-v', 'gh'], {
182
+ reject: false,
183
+ shell: true,
184
+ })
185
+
186
+ return result.exitCode === 0
187
+ }
188
+
189
+ async function read(target: string, args: readonly string[]): Promise<string> {
190
+ const result = await execa('git', ['-C', target, ...args], { reject: false })
191
+ return result.exitCode === 0 ? result.stdout : ''
192
+ }
193
+
194
+ async function mutate(target: string, args: readonly string[]): Promise<void> {
195
+ const result = await execa('git', ['-C', target, ...args], { reject: false })
196
+
197
+ if (result.exitCode !== 0) {
198
+ throw new GitCommandError(
199
+ `git ${args[0]} failed: ${result.stderr || result.stdout}`,
200
+ )
201
+ }
202
+
203
+ if (result.stdout !== '') pipeOutput(result.stdout)
204
+ }