nitm-ai-dev-toolkit 5.0.0 → 5.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.
Files changed (224) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +30 -10
  3. package/bin/cli.js +635 -88
  4. package/package.json +1 -1
  5. package/src/monorepo/.agents/skills/factory/SKILL.md +123 -57
  6. package/src/monorepo/{.claude/agents-snippets/factory-approver.md → .agents/skills/factory/roles/approve.md} +3 -3
  7. package/src/monorepo/{.claude/agents-snippets/factory-builder.md → .agents/skills/factory/roles/build.md} +3 -3
  8. package/src/monorepo/{.claude/agents-snippets/factory-reviewer-code.md → .agents/skills/factory/roles/review-code.md} +5 -5
  9. package/src/{repo/.claude/agents-snippets/factory-reviewer-security.md → monorepo/.agents/skills/factory/roles/review-security.md} +4 -4
  10. package/src/monorepo/{.claude/agents-snippets/factory-reviewer-ui.md → .agents/skills/factory/roles/review-ui.md} +6 -6
  11. package/src/monorepo/{.claude/agents-snippets/factory-reviewer-ux.md → .agents/skills/factory/roles/review-ux.md} +6 -6
  12. package/src/monorepo/{.claude/agents-snippets/factory-spec-writer.md → .agents/skills/factory/roles/spec.md} +2 -2
  13. package/src/monorepo/.claude/agents/implementer.agent.md +1 -1
  14. package/src/monorepo/.claude/agents/planner.agent.md +1 -1
  15. package/src/monorepo/.claude/agents/reviewer.agent.md +1 -1
  16. package/src/monorepo/.claude/commands/assign-tasks.md +1 -0
  17. package/src/monorepo/.claude/commands/factory.md +9 -9
  18. package/src/monorepo/.claude/commands/ponytail-review.md +1 -1
  19. package/src/monorepo/.claude/prompt-snippets/assign-tasks.md +13 -3
  20. package/src/monorepo/.claude/prompt-snippets/bootstrap-patch.md +32 -25
  21. package/src/monorepo/.claude/prompt-snippets/bootstrap-upgrade.md +13 -14
  22. package/src/monorepo/.claude/prompt-snippets/bootstrap.md +12 -12
  23. package/src/monorepo/.claude/prompt-snippets/factory.md +5 -5
  24. package/src/monorepo/.claude/prompt-snippets/implement-feature.md +3 -1
  25. package/src/monorepo/.claude/rules-snippets/subagent-workflow.md +2 -2
  26. package/src/monorepo/.cursor/commands/factory.md +9 -9
  27. package/src/monorepo/.github/prompts/factory.prompt.md +9 -9
  28. package/src/monorepo/.opencode/agents/admin-portal.md +1 -4
  29. package/src/monorepo/.opencode/agents/api-specialist.md +1 -4
  30. package/src/monorepo/.opencode/agents/backend-architect.md +1 -4
  31. package/src/monorepo/.opencode/agents/documenter.md +1 -4
  32. package/src/monorepo/.opencode/agents/feature-builder.md +1 -4
  33. package/src/monorepo/.opencode/agents/frontend-developer.md +1 -4
  34. package/src/monorepo/.opencode/agents/green.md +1 -4
  35. package/src/monorepo/.opencode/agents/implementer.md +1 -4
  36. package/src/monorepo/.opencode/agents/planner.md +1 -4
  37. package/src/monorepo/.opencode/agents/red.md +1 -4
  38. package/src/monorepo/.opencode/agents/refactor.md +1 -4
  39. package/src/monorepo/.opencode/agents/researcher.md +1 -4
  40. package/src/monorepo/.opencode/agents/reviewer.md +1 -4
  41. package/src/monorepo/.opencode/agents/tdd.md +1 -4
  42. package/src/monorepo/.opencode/commands/assign-tasks.md +1 -1
  43. package/src/monorepo/.opencode/commands/bootstrap-patch.md +1 -1
  44. package/src/monorepo/.opencode/commands/bootstrap-upgrade.md +1 -1
  45. package/src/monorepo/.opencode/commands/bootstrap.md +1 -1
  46. package/src/monorepo/.opencode/commands/commit-push.md +1 -1
  47. package/src/monorepo/.opencode/commands/constitution.md +1 -1
  48. package/src/monorepo/.opencode/commands/create-skill.md +1 -1
  49. package/src/monorepo/.opencode/commands/factory.md +9 -9
  50. package/src/monorepo/.opencode/commands/implement-feature.md +1 -1
  51. package/src/monorepo/.opencode/commands/implement-fixes.md +1 -1
  52. package/src/monorepo/.opencode/commands/implement.md +1 -1
  53. package/src/monorepo/.opencode/commands/plan.md +1 -1
  54. package/src/monorepo/.opencode/commands/playwright-test.md +1 -1
  55. package/src/monorepo/.opencode/commands/ponytail-audit.md +1 -1
  56. package/src/monorepo/.opencode/commands/ponytail-debt.md +1 -1
  57. package/src/monorepo/.opencode/commands/ponytail-gain.md +1 -1
  58. package/src/monorepo/.opencode/commands/ponytail-help.md +1 -1
  59. package/src/monorepo/.opencode/commands/ponytail-review.md +1 -1
  60. package/src/monorepo/.opencode/commands/ponytail.md +1 -1
  61. package/src/monorepo/.opencode/commands/release-notes.md +1 -1
  62. package/src/monorepo/.opencode/commands/review-pr.md +1 -1
  63. package/src/monorepo/.opencode/commands/review.md +1 -1
  64. package/src/monorepo/.opencode/commands/specify.md +1 -1
  65. package/src/monorepo/.opencode/commands/tasks.md +1 -1
  66. package/src/monorepo/.opencode/opencode.jsonc +1 -2
  67. package/src/monorepo/.opencode/rules/agent-conduct.md +1 -1
  68. package/src/monorepo/.opencode/rules/api-server.md +1 -1
  69. package/src/monorepo/.opencode/rules/coding-standards.md +1 -1
  70. package/src/monorepo/.opencode/rules/component-structure.md +1 -1
  71. package/src/monorepo/.opencode/rules/core-libraries.md +1 -1
  72. package/src/monorepo/.opencode/rules/environment-tooling.md +1 -1
  73. package/src/monorepo/.opencode/rules/logging-comments.md +1 -1
  74. package/src/monorepo/.opencode/rules/logging.md +1 -1
  75. package/src/monorepo/.opencode/rules/prisma-migrations.md +1 -1
  76. package/src/monorepo/.opencode/rules/project-context.md +1 -1
  77. package/src/monorepo/.opencode/rules/server-client-separation.md +1 -1
  78. package/src/monorepo/.opencode/rules/subagent-workflow.md +1 -1
  79. package/src/monorepo/.opencode/rules/testing-quality.md +1 -1
  80. package/src/monorepo/.opencode/rules/ui-accessibility.md +1 -1
  81. package/src/monorepo/.opencode/rules/version-control.md +1 -1
  82. package/src/monorepo/.opencode/rules/what-to-avoid.md +1 -1
  83. package/src/monorepo/AGENTS.md +4 -4
  84. package/src/monorepo/README.md +5 -5
  85. package/src/monorepo/factory/README.md +51 -24
  86. package/src/monorepo/factory/worktree-bootstrap.md +32 -0
  87. package/src/repo/.agents/skills/factory/SKILL.md +122 -56
  88. package/src/repo/{.claude/agents-snippets/factory-approver.md → .agents/skills/factory/roles/approve.md} +3 -3
  89. package/src/repo/{.claude/agents-snippets/factory-builder.md → .agents/skills/factory/roles/build.md} +3 -3
  90. package/src/repo/{.claude/agents-snippets/factory-reviewer-code.md → .agents/skills/factory/roles/review-code.md} +5 -5
  91. package/src/{monorepo/.claude/agents-snippets/factory-reviewer-security.md → repo/.agents/skills/factory/roles/review-security.md} +4 -4
  92. package/src/repo/{.claude/agents-snippets/factory-reviewer-ui.md → .agents/skills/factory/roles/review-ui.md} +6 -6
  93. package/src/repo/{.claude/agents-snippets/factory-reviewer-ux.md → .agents/skills/factory/roles/review-ux.md} +6 -6
  94. package/src/repo/{.claude/agents-snippets/factory-spec-writer.md → .agents/skills/factory/roles/spec.md} +2 -2
  95. package/src/repo/.claude/agents/implementer.agent.md +1 -1
  96. package/src/repo/.claude/agents/planner.agent.md +1 -1
  97. package/src/repo/.claude/agents/reviewer.agent.md +1 -1
  98. package/src/repo/.claude/commands/assign-tasks.md +1 -0
  99. package/src/repo/.claude/commands/factory.md +9 -9
  100. package/src/repo/.claude/commands/ponytail-review.md +1 -1
  101. package/src/repo/.claude/prompt-snippets/assign-tasks.md +13 -3
  102. package/src/repo/.claude/prompt-snippets/bootstrap-patch.md +32 -25
  103. package/src/repo/.claude/prompt-snippets/bootstrap-upgrade.md +17 -17
  104. package/src/repo/.claude/prompt-snippets/bootstrap.md +12 -12
  105. package/src/repo/.claude/prompt-snippets/factory.md +5 -5
  106. package/src/repo/.claude/prompt-snippets/implement-feature.md +3 -1
  107. package/src/repo/.claude/rules-snippets/subagent-workflow.md +2 -2
  108. package/src/repo/.cursor/commands/factory.md +9 -9
  109. package/src/repo/.github/prompts/factory.prompt.md +9 -9
  110. package/src/repo/.opencode/agents/admin-portal.md +1 -4
  111. package/src/repo/.opencode/agents/api-specialist.md +1 -4
  112. package/src/repo/.opencode/agents/backend-architect.md +1 -4
  113. package/src/repo/.opencode/agents/documenter.md +1 -4
  114. package/src/repo/.opencode/agents/feature-builder.md +1 -4
  115. package/src/repo/.opencode/agents/frontend-developer.md +1 -4
  116. package/src/repo/.opencode/agents/green.md +1 -4
  117. package/src/repo/.opencode/agents/implementer.md +1 -4
  118. package/src/repo/.opencode/agents/planner.md +1 -4
  119. package/src/repo/.opencode/agents/red.md +1 -4
  120. package/src/repo/.opencode/agents/refactor.md +1 -4
  121. package/src/repo/.opencode/agents/researcher.md +1 -4
  122. package/src/repo/.opencode/agents/reviewer.md +1 -4
  123. package/src/repo/.opencode/agents/tdd.md +1 -4
  124. package/src/repo/.opencode/commands/assign-tasks.md +1 -1
  125. package/src/repo/.opencode/commands/bootstrap-patch.md +1 -1
  126. package/src/repo/.opencode/commands/bootstrap-upgrade.md +1 -1
  127. package/src/repo/.opencode/commands/bootstrap.md +1 -1
  128. package/src/repo/.opencode/commands/commit-push.md +1 -1
  129. package/src/repo/.opencode/commands/constitution.md +1 -1
  130. package/src/repo/.opencode/commands/create-skill.md +1 -1
  131. package/src/repo/.opencode/commands/factory.md +9 -9
  132. package/src/repo/.opencode/commands/implement-feature.md +1 -1
  133. package/src/repo/.opencode/commands/implement-fixes.md +1 -1
  134. package/src/repo/.opencode/commands/implement.md +1 -1
  135. package/src/repo/.opencode/commands/plan.md +1 -1
  136. package/src/repo/.opencode/commands/playwright-test.md +1 -1
  137. package/src/repo/.opencode/commands/ponytail-audit.md +1 -1
  138. package/src/repo/.opencode/commands/ponytail-debt.md +1 -1
  139. package/src/repo/.opencode/commands/ponytail-gain.md +1 -1
  140. package/src/repo/.opencode/commands/ponytail-help.md +1 -1
  141. package/src/repo/.opencode/commands/ponytail-review.md +1 -1
  142. package/src/repo/.opencode/commands/ponytail.md +1 -1
  143. package/src/repo/.opencode/commands/release-notes.md +1 -1
  144. package/src/repo/.opencode/commands/review-pr.md +1 -1
  145. package/src/repo/.opencode/commands/review.md +1 -1
  146. package/src/repo/.opencode/commands/specify.md +1 -1
  147. package/src/repo/.opencode/commands/tasks.md +1 -1
  148. package/src/repo/.opencode/opencode.jsonc +1 -2
  149. package/src/repo/.opencode/rules/agent-conduct.md +1 -1
  150. package/src/repo/.opencode/rules/api-server.md +1 -1
  151. package/src/repo/.opencode/rules/coding-standards.md +1 -1
  152. package/src/repo/.opencode/rules/component-structure.md +1 -1
  153. package/src/repo/.opencode/rules/core-libraries.md +1 -1
  154. package/src/repo/.opencode/rules/environment-tooling.md +1 -1
  155. package/src/repo/.opencode/rules/logging-comments.md +1 -1
  156. package/src/repo/.opencode/rules/logging.md +1 -1
  157. package/src/repo/.opencode/rules/prisma-migrations.md +1 -1
  158. package/src/repo/.opencode/rules/project-context.md +1 -1
  159. package/src/repo/.opencode/rules/server-client-separation.md +1 -1
  160. package/src/repo/.opencode/rules/subagent-workflow.md +1 -1
  161. package/src/repo/.opencode/rules/testing-quality.md +1 -1
  162. package/src/repo/.opencode/rules/ui-accessibility.md +1 -1
  163. package/src/repo/.opencode/rules/version-control.md +1 -1
  164. package/src/repo/.opencode/rules/what-to-avoid.md +1 -1
  165. package/src/repo/AGENTS.md +4 -4
  166. package/src/repo/README.md +5 -5
  167. package/src/repo/factory/README.md +44 -23
  168. package/src/repo/factory/worktree-bootstrap.md +32 -0
  169. package/src/monorepo/.claude/agents/factory-approver.agent.md +0 -12
  170. package/src/monorepo/.claude/agents/factory-builder.agent.md +0 -12
  171. package/src/monorepo/.claude/agents/factory-reviewer-code.agent.md +0 -12
  172. package/src/monorepo/.claude/agents/factory-reviewer-security.agent.md +0 -12
  173. package/src/monorepo/.claude/agents/factory-reviewer-ui.agent.md +0 -12
  174. package/src/monorepo/.claude/agents/factory-reviewer-ux.agent.md +0 -12
  175. package/src/monorepo/.claude/agents/factory-spec-writer.agent.md +0 -12
  176. package/src/monorepo/.cursor/agents/factory-approver.agent.md +0 -11
  177. package/src/monorepo/.cursor/agents/factory-builder.agent.md +0 -11
  178. package/src/monorepo/.cursor/agents/factory-reviewer-code.agent.md +0 -11
  179. package/src/monorepo/.cursor/agents/factory-reviewer-security.agent.md +0 -11
  180. package/src/monorepo/.cursor/agents/factory-reviewer-ui.agent.md +0 -11
  181. package/src/monorepo/.cursor/agents/factory-reviewer-ux.agent.md +0 -11
  182. package/src/monorepo/.cursor/agents/factory-spec-writer.agent.md +0 -11
  183. package/src/monorepo/.github/agents/factory-approver.agent.md +0 -11
  184. package/src/monorepo/.github/agents/factory-builder.agent.md +0 -11
  185. package/src/monorepo/.github/agents/factory-reviewer-code.agent.md +0 -11
  186. package/src/monorepo/.github/agents/factory-reviewer-security.agent.md +0 -11
  187. package/src/monorepo/.github/agents/factory-reviewer-ui.agent.md +0 -11
  188. package/src/monorepo/.github/agents/factory-reviewer-ux.agent.md +0 -11
  189. package/src/monorepo/.github/agents/factory-spec-writer.agent.md +0 -11
  190. package/src/monorepo/.opencode/agents/factory-approver.md +0 -20
  191. package/src/monorepo/.opencode/agents/factory-builder.md +0 -20
  192. package/src/monorepo/.opencode/agents/factory-reviewer-code.md +0 -20
  193. package/src/monorepo/.opencode/agents/factory-reviewer-security.md +0 -20
  194. package/src/monorepo/.opencode/agents/factory-reviewer-ui.md +0 -20
  195. package/src/monorepo/.opencode/agents/factory-reviewer-ux.md +0 -20
  196. package/src/monorepo/.opencode/agents/factory-spec-writer.md +0 -20
  197. package/src/repo/.claude/agents/factory-approver.agent.md +0 -12
  198. package/src/repo/.claude/agents/factory-builder.agent.md +0 -12
  199. package/src/repo/.claude/agents/factory-reviewer-code.agent.md +0 -12
  200. package/src/repo/.claude/agents/factory-reviewer-security.agent.md +0 -12
  201. package/src/repo/.claude/agents/factory-reviewer-ui.agent.md +0 -12
  202. package/src/repo/.claude/agents/factory-reviewer-ux.agent.md +0 -12
  203. package/src/repo/.claude/agents/factory-spec-writer.agent.md +0 -12
  204. package/src/repo/.cursor/agents/factory-approver.agent.md +0 -11
  205. package/src/repo/.cursor/agents/factory-builder.agent.md +0 -11
  206. package/src/repo/.cursor/agents/factory-reviewer-code.agent.md +0 -11
  207. package/src/repo/.cursor/agents/factory-reviewer-security.agent.md +0 -11
  208. package/src/repo/.cursor/agents/factory-reviewer-ui.agent.md +0 -11
  209. package/src/repo/.cursor/agents/factory-reviewer-ux.agent.md +0 -11
  210. package/src/repo/.cursor/agents/factory-spec-writer.agent.md +0 -11
  211. package/src/repo/.github/agents/factory-approver.agent.md +0 -11
  212. package/src/repo/.github/agents/factory-builder.agent.md +0 -11
  213. package/src/repo/.github/agents/factory-reviewer-code.agent.md +0 -11
  214. package/src/repo/.github/agents/factory-reviewer-security.agent.md +0 -11
  215. package/src/repo/.github/agents/factory-reviewer-ui.agent.md +0 -11
  216. package/src/repo/.github/agents/factory-reviewer-ux.agent.md +0 -11
  217. package/src/repo/.github/agents/factory-spec-writer.agent.md +0 -11
  218. package/src/repo/.opencode/agents/factory-approver.md +0 -20
  219. package/src/repo/.opencode/agents/factory-builder.md +0 -20
  220. package/src/repo/.opencode/agents/factory-reviewer-code.md +0 -20
  221. package/src/repo/.opencode/agents/factory-reviewer-security.md +0 -20
  222. package/src/repo/.opencode/agents/factory-reviewer-ui.md +0 -20
  223. package/src/repo/.opencode/agents/factory-reviewer-ux.md +0 -20
  224. package/src/repo/.opencode/agents/factory-spec-writer.md +0 -20
package/bin/cli.js CHANGED
@@ -8,13 +8,15 @@
8
8
 
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
- const { execSync, spawnSync } = require('child_process');
11
+ const crypto = require('crypto');
12
+ const { execFileSync, spawnSync } = require('child_process');
12
13
  const factory = require('./factory-watch.js');
13
14
  const factoryArchive = require('./factory-archive.js');
14
15
  const factoryPrune = require('./factory-prune.js');
15
16
 
16
17
  const PKG_ROOT = path.join(__dirname, '..');
17
18
  const SRC_DIR = path.join(PKG_ROOT, 'src');
19
+ const PKG_NAME = 'nitm-ai-dev-toolkit';
18
20
 
19
21
  // Platform config dirs per harness. Claude and Codex share `.agents` (universal skills).
20
22
  const ENV_DIRS = {
@@ -47,6 +49,77 @@ const SHARED_DIRS = ['docs', 'templates', 'hooks', 'factory', path.join('.agents
47
49
  // npm drops `.gitignore` files from packages, so templates ship them as `_gitignore`
48
50
  // and install them under the real name.
49
51
  const TEMPLATE_RENAMES = { '_gitignore': '.gitignore' };
52
+
53
+ // Files the CLI writes in the target repo besides template files.
54
+ const VERSION_FILE = '.toolkit-version';
55
+ const NITM_DIR = '.nitm';
56
+ const HANDOFF_FILE = path.join(NITM_DIR, 'BOOTSTRAP.md');
57
+ const CONFIG_FILE = path.join(NITM_DIR, 'toolkit.json');
58
+ const MANIFEST_FILE = path.join(NITM_DIR, 'manifest.json');
59
+ const BACKUP_DIR = path.join(NITM_DIR, 'backups');
60
+
61
+ // Groups for --exclude / --only. Explicit closed lists: nothing is detected from the stack.
62
+ const HARNESS_GROUPS = Object.keys(ENV_DIRS);
63
+ const FEATURE_GROUPS = ['factory', 'prisma', 'bkt-cli', 'gh-cli', 'glab-cli', 'acli', 'linear-cli'];
64
+ const SELECTABLE_GROUPS = [...HARNESS_GROUPS, ...FEATURE_GROUPS];
65
+ // Top-level dirs owned by one harness. SHARED_ROOT, SHARED_DIRS, `.agents/*` and
66
+ // `.github/instructions` are shared, so they carry no harness group.
67
+ const HARNESS_OWNED = {
68
+ '.codex': 'codex', '.cursor': 'cursor', '.vscode': 'copilot', '.github': 'copilot',
69
+ '.opencode': 'opencode', '.claude': 'claude',
70
+ };
71
+
72
+ // Known historical template hashes (sha256 of the installed file), by target path.
73
+ // Doctor and patch report stale copies that match; they never delete anything.
74
+ // `.opencode/plugins/ponytail.mjs`: the src/repo and src/monorepo template copy shipped
75
+ // through 3.0.23 (git 3.0.23:src/repo/.opencode/plugins/ponytail.mjs), removed in 4.0.0 (f57f44d).
76
+ // test/data/ponytail-3.0.23.mjs.fixture holds that exact content.
77
+ const KNOWN_HISTORICAL = {
78
+ '.opencode/plugins/ponytail.mjs': ['3ca6fe6e560da71109af7e40ed04ca47aa22219e4c9edf674bf895acadce8605'],
79
+ // factory-* agents and their snippets, removed in 5.1.0 (repo and monorepo versions of each file).
80
+ // The .opencode/agents entries also carry the 5.0.0 hash (5.0.1 changed them).
81
+ '.claude/agents/factory-spec-writer.agent.md': ['7d12cd197d71cd52d31d4e17f756abd984c012c786cff8c23465b637e2c1cb30'],
82
+ '.cursor/agents/factory-spec-writer.agent.md': ['03c70de3a9b570972ff14e5dcd946b22948fb216e075452df46ccd4f4bd457cf'],
83
+ '.github/agents/factory-spec-writer.agent.md': ['76f87fe65a7a552f4645d67b375c464943fe2d92bcbfac4a91d8880cb726b4b2'],
84
+ '.opencode/agents/factory-spec-writer.md': ['591ba39990f2037f0093b2aaf5a178c269c5b4d723a8dcf61a6a3cbb350ce1cc', '2c778565cbe80f0d610e886f2c150ad9a6478a60c8e7653eb7ece8fe1b7d85d7'],
85
+ '.claude/agents-snippets/factory-spec-writer.md': ['cdb34e0195981008c0962df78bcdbe2c53e04ef930e0d1d456e02162d4553528', '4644730e83f4ae5eb7b5b8b78021cb9cd9db81bdbc32fcfb745251a8326343b0'],
86
+ '.claude/agents/factory-builder.agent.md': ['8cc3434f6ee7e1a24098f15428f0810bfe0421288992b1bf4783d4304e0ee999'],
87
+ '.cursor/agents/factory-builder.agent.md': ['4e14b4588baf99bc98437bf85ce3fc67bf94a30fa0243c7e7cf67c716e75a852'],
88
+ '.github/agents/factory-builder.agent.md': ['1f7998713df8f5163ea231ce37103cd8be5f757ffa8b8145888dbc889a202807'],
89
+ '.opencode/agents/factory-builder.md': ['d35fde702d4e8f3876f5f3bbe3b1dd5c16b0f20b375a9b6c6ad0508e0a8b773e', '87e2d9bcc4b4bb387782e202c8b26a8c0a5ca63d86a3f1f0b018698f7fc56a6c'],
90
+ '.claude/agents-snippets/factory-builder.md': ['08b757399ebb72b57df279cd47a9a681e285e9615151bc6354b58ceab12f45a3', '5526e25aee44ebcce73c868a65ad61221b63575466eb25f05c1f98cce5c1c36b'],
91
+ '.claude/agents/factory-reviewer-code.agent.md': ['b61ff8ec33b5f0fc9890046e15d6e0886d45e9a472602e75d362537a4ef8f181'],
92
+ '.cursor/agents/factory-reviewer-code.agent.md': ['303e314cdeb6637e6d76adc257a65095a14ff7b578f7dc8e653ccb804ce1c4ea'],
93
+ '.github/agents/factory-reviewer-code.agent.md': ['cd729889637f543d79d4daf9e09df07633ff66835c0412dddb5443a729bdd278'],
94
+ '.opencode/agents/factory-reviewer-code.md': ['bbe16860782736a8389dfb94647dfc4fb0cae1ad46617d34f4e896df29d73489', '681c93ed0432c93898f8f53f9ff78b13e6bb618f9bf8c3f0e6fb2b9083afa05e'],
95
+ '.claude/agents-snippets/factory-reviewer-code.md': ['92a1726bdec0d94222b08654fa8db481e1710163fd06f439019ab737bdae55c8', '44097bf782912e34eb1d0946cb9ed42be8753fea13036db8edcbb9524b7be8b2'],
96
+ '.claude/agents/factory-reviewer-security.agent.md': ['7685bb4a2433c7069bdf6f48b7264f46febfe905cd0a8315dd7cefeae2d77add'],
97
+ '.cursor/agents/factory-reviewer-security.agent.md': ['8bfce3dfbaab8d4a9d7d2b9ec2fd067214028bd8578a53af3ae0933898b0cb2e'],
98
+ '.github/agents/factory-reviewer-security.agent.md': ['96f0e8697dabb0567b67426214809de870e06df30c1058a4753ac3bf13cff456'],
99
+ '.opencode/agents/factory-reviewer-security.md': ['55895e193aec20ffcb42a7c12553116f777c91db97e107f240ab15db0d85a7c0', '9302937a79f89c703c6fdb60cfbd65985cd00e52e580786d3be0a4fe8e8ae213'],
100
+ '.claude/agents-snippets/factory-reviewer-security.md': ['8a4c5a411e47d5b1e50b18d40d177e931cc6bf20a96dfb4286a39719f863b37d'],
101
+ '.claude/agents/factory-reviewer-ux.agent.md': ['4ff8769a04fcfee2214243b5f1099e69f8fefc8760261321c1cbeb9ab796cb85'],
102
+ '.cursor/agents/factory-reviewer-ux.agent.md': ['00155aabea522cfa0f20a2c03d194e374958130ec8fc8bb88210ab6f2185fcfa'],
103
+ '.github/agents/factory-reviewer-ux.agent.md': ['2965a81ebf7d455e07c9270fc5066480e34c8168c552d0fab6602b826caaed8d'],
104
+ '.opencode/agents/factory-reviewer-ux.md': ['7175b4b5ca713a8b15c0909c84eb2bb6e29630ffeb90157f9e2df9b6a62a6649', 'c192cc5f1dffcfa8879a3d9ede7270e1098fa25dcd2f989627b217edc4b9446d'],
105
+ '.claude/agents-snippets/factory-reviewer-ux.md': ['a16b188d275f3fba1527b3ef510f61ea3bada11594488720146a8560b17efb98', '6e114305e5d18b284d7f9ececa6b64d6f06c5a272ec2651dcdaea74e81e95554'],
106
+ '.claude/agents/factory-reviewer-ui.agent.md': ['1107289a1429c795a7c9a8c16426692ca62593483c0ff8b6863a96de166f1cfc'],
107
+ '.cursor/agents/factory-reviewer-ui.agent.md': ['388b66c8368d642ccbdcb35c55a0c994d510d85b9c04b3f926806d0c1bf824de'],
108
+ '.github/agents/factory-reviewer-ui.agent.md': ['97aed62c6716fa96834985704dc0e34850842cbd92ceaa987acd70f42769e3dc'],
109
+ '.opencode/agents/factory-reviewer-ui.md': ['b1e1a5544df1cdd54fedf0a65fd54007600e29ec54a423055858780b363c6d60', 'f93c2410ac2a9d3b358ac21085dd1804e111981b8d24495c411ba06dd6aa5495'],
110
+ '.claude/agents-snippets/factory-reviewer-ui.md': ['7399863904241ab875cfb7862c683b1135eb4f8027a4a422e377b016007f7bf5', '0142ec37dc2306320d613b58505c31852c02f502b80c6070ff72b5eb77d8b2e1'],
111
+ '.claude/agents/factory-approver.agent.md': ['2de2420de55a72268f24672a92a4df0d78e133b14a9ccf62ab4e2b0b8f1c090c'],
112
+ '.cursor/agents/factory-approver.agent.md': ['5bc3df10f0784804bcd54fe4a3de60c6116c55d34eb85bc5be0f1417613bc25c'],
113
+ '.github/agents/factory-approver.agent.md': ['2a061b34802eb9b5396b76d139bbea5dd21dd89b3e5af06cf09c53c7f6897d94'],
114
+ '.opencode/agents/factory-approver.md': ['62dbfb689025bf79a2e8f86f7a5afb52a73e07e677ff384d26301d494c3ba21f', '35b3b45950ae1a36ebd8e8b7d46d8499ed427a138d422457a72de2f98c891b0c'],
115
+ '.claude/agents-snippets/factory-approver.md': ['cacf515a661d25120824d38a81ef2a5753c3bfc5fce8732728cbdd394dda6857'],
116
+ };
117
+
118
+ // First release with --dry-run, --exclude and --only. `upgrade` refuses a dry run below it (older
119
+ // releases would write), and refuses a selection below it (older releases would ignore it).
120
+ const DRY_RUN_UPGRADE_MIN = '5.0.1';
121
+ const VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
122
+
50
123
  const C = {
51
124
  reset: '\x1b[0m',
52
125
  cyan: '\x1b[36m',
@@ -59,6 +132,24 @@ function ok(m) { console.log(`${C.green}✓${C.reset} ${m}`); }
59
132
  function warn(m) { console.log(`${C.yellow}!${C.reset} ${m}`); }
60
133
  function err(m) { console.log(`${C.red}✗${C.reset} ${m}`); }
61
134
 
135
+ function sha256(buf) { return crypto.createHash('sha256').update(buf).digest('hex'); }
136
+ function fileHash(p) { return sha256(fs.readFileSync(p)); }
137
+ function readJson(p) {
138
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (_) { return null; }
139
+ }
140
+ function packageVersion() {
141
+ return JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8')).version;
142
+ }
143
+ function writeText(cwd, rel, text) {
144
+ const to = path.join(cwd, rel);
145
+ fs.mkdirSync(path.dirname(to), { recursive: true });
146
+ fs.writeFileSync(to, text);
147
+ }
148
+ // 'unchanged' when the file already holds exactly `text`, otherwise 'write'.
149
+ function writeState(p, text) {
150
+ return fs.existsSync(p) && fs.readFileSync(p, 'utf8') === text ? 'unchanged' : 'write';
151
+ }
152
+
62
153
  function detectType(cwd, flags) {
63
154
  if (flags.monorepo) return 'monorepo';
64
155
  if (flags.repo) return 'repo';
@@ -116,45 +207,175 @@ function scopeFiles(files, env) {
116
207
  });
117
208
  }
118
209
 
119
- function copyMissing(type, cwd, force, env) {
120
- const src = path.join(SRC_DIR, type);
121
- const files = scopeFiles(templateFiles(type), env);
122
- let copied = 0;
123
- let skipped = 0;
124
- for (const rel of files) {
125
- const from = path.join(src, rel);
210
+ // ---- Group selection (--exclude / --only) ----
211
+
212
+ function inDir(rel, dir) {
213
+ return rel === dir || rel.startsWith(dir + path.sep);
214
+ }
215
+
216
+ // The harness that owns a file, or null when the file is shared.
217
+ function harnessOf(rel) {
218
+ const n = path.normalize(rel);
219
+ if (SHARED_ROOT.includes(n) || SHARED_DIRS.some((d) => inDir(n, path.normalize(d)))) return null;
220
+ if (inDir(n, path.join('.github', 'instructions'))) return null;
221
+ return HARNESS_OWNED[n.split(path.sep)[0]] || null;
222
+ }
223
+
224
+ // Feature groups a file belongs to: a path segment or its file stem equals the group name
225
+ // (factory/, .agents/skills/gh-cli/, prisma-migrations.md).
226
+ function featuresOf(rel) {
227
+ const n = path.normalize(rel);
228
+ const segments = n.split(path.sep).map((s) => s.replace(/^\./, ''));
229
+ const stem = path.basename(n).split('.')[0];
230
+ return FEATURE_GROUPS.filter((g) => segments.includes(g) || stem === g || stem.startsWith(g + '-'));
231
+ }
232
+
233
+ function groupsOf(rel) {
234
+ const harness = harnessOf(rel);
235
+ return harness ? [harness, ...featuresOf(rel)] : featuresOf(rel);
236
+ }
237
+
238
+ // Selection = { exclude, only } as lists of group names. Shared (untagged) files are always kept.
239
+ // Exclude drops files in any listed group. Only keeps tagged files in a listed group.
240
+ function selectionKeeps(rel, sel) {
241
+ if (!sel) return true;
242
+ const groups = groupsOf(rel);
243
+ if (groups.some((g) => sel.exclude.includes(g))) return false;
244
+ if (sel.only.length && groups.length && !groups.some((g) => sel.only.includes(g))) return false;
245
+ return true;
246
+ }
247
+
248
+ function normalizeSelection(input) {
249
+ const sel = { exclude: [], only: [] };
250
+ for (const key of ['exclude', 'only']) {
251
+ const list = (input && input[key]) || [];
252
+ if (!Array.isArray(list)) throw new Error(`${key} must be a list of group names`);
253
+ for (const g of list) {
254
+ if (!SELECTABLE_GROUPS.includes(g)) {
255
+ throw new Error(`Unknown group "${g}" for --${key}. Valid groups: ${SELECTABLE_GROUPS.join(', ')}`);
256
+ }
257
+ }
258
+ sel[key] = [...new Set(list)].sort();
259
+ }
260
+ return sel;
261
+ }
262
+
263
+ function splitGroups(value) {
264
+ return value.split(',').map((s) => s.trim()).filter(Boolean);
265
+ }
266
+
267
+ // A missing saved config means no selection. One that exists but does not parse is an error:
268
+ // treating it as empty would let patch add files the user excluded.
269
+ function readConfigOrThrow(cwd) {
270
+ const p = path.join(cwd, CONFIG_FILE);
271
+ if (!fs.existsSync(p)) return null;
272
+ try {
273
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
274
+ } catch (ex) {
275
+ throw new Error(`${CONFIG_FILE} exists but is not valid JSON (${ex.message}). Fix or remove it; nothing was written.`);
276
+ }
277
+ }
278
+
279
+ // Explicit --exclude/--only replace the saved selection; without them the saved one applies.
280
+ function resolveSelection(cwd, flags) {
281
+ const hasExclude = flags.exclude !== undefined;
282
+ const hasOnly = flags.only !== undefined;
283
+ if (hasExclude && hasOnly) throw new Error('Use either --exclude or --only, not both');
284
+ if (hasExclude || hasOnly) {
285
+ return { selection: normalizeSelection({ exclude: flags.exclude, only: flags.only }), explicit: true };
286
+ }
287
+ return { selection: normalizeSelection(readConfigOrThrow(cwd)), explicit: false };
288
+ }
289
+
290
+ function configText(sel) {
291
+ return JSON.stringify({ exclude: sel.exclude, only: sel.only }, null, 2) + '\n';
292
+ }
293
+
294
+ // ---- Planning ----
295
+
296
+ // The path that blocks writing `dest` (an ancestor that is a file, or `dest` itself as a directory).
297
+ function blockerOf(cwd, dest) {
298
+ const parts = path.normalize(dest).split(path.sep);
299
+ for (let i = 1; i <= parts.length; i++) {
300
+ const p = path.join(cwd, ...parts.slice(0, i));
301
+ if (!fs.existsSync(p)) return null;
302
+ const isDir = fs.statSync(p).isDirectory();
303
+ if (i < parts.length && !isDir) return parts.slice(0, i).join(path.sep);
304
+ if (i === parts.length && isDir) return dest;
305
+ }
306
+ return null;
307
+ }
308
+
309
+ // One entry per template file the command touches.
310
+ // add: missing in the target
311
+ // overwrite: install --force on an existing file
312
+ // skip: patch keeps an existing file (identical or user-changed)
313
+ // conflict: install without --force on an existing file, or a path blocked by a file/dir of another type
314
+ function planFiles(type, cwd, { mode, force, env, selection }) {
315
+ const rels = scopeFiles(templateFiles(type), env).filter((rel) => selectionKeeps(rel, selection));
316
+ return rels.map((rel) => {
126
317
  const dest = installName(rel);
318
+ const from = path.join(SRC_DIR, type, rel);
319
+ const hash = fileHash(from);
320
+ const blocker = blockerOf(cwd, dest);
321
+ if (blocker) return { rel: dest, from, hash, action: 'conflict', reason: `blocked by ${blocker}` };
127
322
  const to = path.join(cwd, dest);
128
- if (fs.existsSync(to) && !force) {
129
- skipped++;
130
- continue;
131
- }
323
+ if (!fs.existsSync(to)) return { rel: dest, from, hash, action: 'add', same: false };
324
+ const same = fileHash(to) === hash;
325
+ if (mode === 'install' && !force) return { rel: dest, from, hash, action: 'conflict', reason: 'exists', same };
326
+ if (mode === 'install') return { rel: dest, from, hash, action: 'overwrite', same };
327
+ return { rel: dest, from, hash, action: 'skip', same, reason: same ? 'identical' : 'kept, differs from template' };
328
+ });
329
+ }
330
+
331
+ function countActions(plan) {
332
+ const counts = { add: 0, overwrite: 0, skip: 0, conflict: 0 };
333
+ for (const e of plan) counts[e.action]++;
334
+ return counts;
335
+ }
336
+
337
+ function applyPlan(cwd, plan) {
338
+ let copied = 0;
339
+ for (const e of plan) {
340
+ if (e.action !== 'add' && e.action !== 'overwrite') continue;
341
+ const to = path.join(cwd, e.rel);
132
342
  try {
133
343
  fs.mkdirSync(path.dirname(to), { recursive: true });
134
- } catch (e) {
135
- if (e.code !== 'EEXIST') throw new Error(`Failed to create directory for ${rel}: ${e.message}`);
344
+ } catch (ex) {
345
+ throw new Error(`Failed to create directory for ${e.rel}: ${ex.message}`);
136
346
  }
137
347
  try {
138
- fs.copyFileSync(from, to);
139
- } catch (e) {
140
- throw new Error(`Failed to copy ${rel}: ${e.message}`);
348
+ fs.copyFileSync(e.from, to);
349
+ } catch (ex) {
350
+ throw new Error(`Failed to copy ${e.rel}: ${ex.message}`);
141
351
  }
142
352
  copied++;
143
353
  }
144
- return { copied, skipped, total: files.length };
354
+ return copied;
145
355
  }
146
356
 
147
357
  // Files the scaffold would write that already exist in the target repo.
148
358
  function conflicts(type, cwd, env) {
149
- const files = scopeFiles(templateFiles(type), env);
150
- const out = [];
151
- for (const rel of files) {
152
- const dest = installName(rel);
153
- if (fs.existsSync(path.join(cwd, dest))) out.push(dest);
359
+ return planFiles(type, cwd, { mode: 'install', force: false, env, selection: null })
360
+ .filter((e) => e.action === 'conflict')
361
+ .map((e) => e.rel);
362
+ }
363
+
364
+ function printPlan(plan) {
365
+ const c = countActions(plan);
366
+ info(`Dry run, nothing will be written: ${c.add} add, ${c.overwrite} overwrite, ${c.skip} skip, ${c.conflict} conflict.`);
367
+ const sections = [['add', 'Would add'], ['overwrite', 'Would overwrite'],
368
+ ['skip', 'Would skip (existing, kept)'], ['conflict', 'Conflicts']];
369
+ for (const [action, label] of sections) {
370
+ const list = plan.filter((e) => e.action === action);
371
+ if (!list.length) continue;
372
+ console.log(` ${label} (${list.length}):`);
373
+ for (const e of list) console.log(` - ${e.rel}${e.reason ? ` (${e.reason})` : ''}`);
154
374
  }
155
- return out;
156
375
  }
157
376
 
377
+ // ---- Handoff, version, manifest, selection (the files written besides templates) ----
378
+
158
379
  function envHeader(type, env) {
159
380
  if (!env || !ENV_INFO[env]) {
160
381
  return [
@@ -188,14 +409,129 @@ function envHeader(type, env) {
188
409
  ].join('\n');
189
410
  }
190
411
 
191
- function handoff(type, cwd, env) {
412
+ function handoffContent(type, env) {
192
413
  const bootSrc = path.join(SRC_DIR, type, '.claude', 'prompt-snippets', 'bootstrap.md');
193
- const nitmDir = path.join(cwd, '.nitm');
194
- fs.mkdirSync(nitmDir, { recursive: true });
195
- const header = envHeader(type, env);
196
- let body = '';
197
- if (fs.existsSync(bootSrc)) body = fs.readFileSync(bootSrc, 'utf8');
198
- fs.writeFileSync(path.join(nitmDir, 'BOOTSTRAP.md'), header + '\n\n' + body);
414
+ const body = fs.existsSync(bootSrc) ? fs.readFileSync(bootSrc, 'utf8') : '';
415
+ return envHeader(type, env) + '\n\n' + body;
416
+ }
417
+
418
+ // create | unchanged | replace. A replaced handoff is backed up first, never lost.
419
+ function handoffState(cwd, content) {
420
+ const p = path.join(cwd, HANDOFF_FILE);
421
+ if (!fs.existsSync(p)) return 'create';
422
+ return fs.readFileSync(p, 'utf8') === content ? 'unchanged' : 'replace';
423
+ }
424
+
425
+ // Copies the current handoff to a new backup name. Existing backups are never overwritten:
426
+ // COPYFILE_EXCL fails on a taken name and we retry with a numeric suffix.
427
+ function backupHandoff(cwd) {
428
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
429
+ fs.mkdirSync(path.join(cwd, BACKUP_DIR), { recursive: true });
430
+ for (let n = 0; ; n++) {
431
+ const rel = path.join(BACKUP_DIR, `BOOTSTRAP.${stamp}${n ? `-${n}` : ''}.md`);
432
+ try {
433
+ fs.copyFileSync(path.join(cwd, HANDOFF_FILE), path.join(cwd, rel), fs.constants.COPYFILE_EXCL);
434
+ return rel;
435
+ } catch (ex) {
436
+ if (ex.code !== 'EEXIST') throw ex;
437
+ }
438
+ }
439
+ }
440
+
441
+ function readManifest(cwd) {
442
+ const m = readJson(path.join(cwd, MANIFEST_FILE));
443
+ return m && m.files && typeof m.files === 'object' ? m : { files: {} };
444
+ }
445
+
446
+ // Manifest keys are template paths written with '/'. Anything absolute or escaping the target is ignored.
447
+ function safeManifestPath(key) {
448
+ const rel = String(key).replace(/\\/g, '/');
449
+ if (path.posix.isAbsolute(rel) || path.win32.isAbsolute(rel) || rel.split('/').includes('..')) return null;
450
+ return rel;
451
+ }
452
+
453
+ function manifestText(files) {
454
+ const sorted = Object.fromEntries(Object.keys(files).sort().map((k) => [k, files[k]]));
455
+ return JSON.stringify({ toolkit: packageVersion(), files: sorted }, null, 2) + '\n';
456
+ }
457
+
458
+ // The toolkit version this project was scaffolded or patched with, against the package version.
459
+ function toolkitVersionStatus(cwd) {
460
+ const pkg = packageVersion();
461
+ const p = path.join(cwd, VERSION_FILE);
462
+ const installed = fs.existsSync(p) ? fs.readFileSync(p, 'utf8').trim() : null;
463
+ const status = installed === null ? 'missing' : installed === pkg ? 'match' : 'mismatch';
464
+ return { installed, package: pkg, status };
465
+ }
466
+
467
+ // Known historical toolkit files still on disk. Matched by path AND content hash, so user files never match.
468
+ // Reported only. Nothing is deleted.
469
+ function findStale(cwd, type) {
470
+ const current = new Set(templateFiles(type).map(installName));
471
+ const stale = [];
472
+ for (const [key, hashes] of Object.entries(KNOWN_HISTORICAL)) {
473
+ const rel = path.join(...key.split('/'));
474
+ const p = path.join(cwd, rel);
475
+ if (current.has(rel) || !fs.existsSync(p) || !fs.statSync(p).isFile()) continue;
476
+ if (hashes.includes(fileHash(p))) stale.push(rel);
477
+ }
478
+ return stale;
479
+ }
480
+
481
+ // Manifest-owned files whose content no longer matches what the toolkit recorded (edited by the user).
482
+ function customizedOwned(cwd) {
483
+ const out = [];
484
+ for (const [key, hash] of Object.entries(readManifest(cwd).files)) {
485
+ const rel = safeManifestPath(key);
486
+ if (rel === null) continue;
487
+ const p = path.join(cwd, ...rel.split('/'));
488
+ if (fs.existsSync(p) && fs.statSync(p).isFile() && fileHash(p) !== hash) out.push(rel);
489
+ }
490
+ return out;
491
+ }
492
+
493
+ // Everything install/patch writes besides template files. Writes only when not dryRun.
494
+ function recordState(cwd, { type, env, plan, selection, explicit, dryRun }) {
495
+ const content = handoffContent(type, env);
496
+ const handoff = handoffState(cwd, content);
497
+ const versionText = packageVersion() + '\n';
498
+ const version = writeState(path.join(cwd, VERSION_FILE), versionText);
499
+ const files = { ...readManifest(cwd).files };
500
+ for (const e of plan) {
501
+ if (e.action === 'add' || e.action === 'overwrite' || (e.action === 'skip' && e.same)) {
502
+ files[e.rel.split(path.sep).join('/')] = e.hash;
503
+ }
504
+ }
505
+ const manifest = manifestText(files);
506
+ const manifestState = writeState(path.join(cwd, MANIFEST_FILE), manifest);
507
+ const config = explicit ? writeState(path.join(cwd, CONFIG_FILE), configText(selection)) : null;
508
+ const state = { handoff, version, manifest: manifestState, config, backup: null };
509
+ if (dryRun) return state;
510
+ if (config === 'write') writeText(cwd, CONFIG_FILE, configText(selection));
511
+ if (handoff === 'replace') state.backup = backupHandoff(cwd);
512
+ if (handoff !== 'unchanged') writeText(cwd, HANDOFF_FILE, content);
513
+ if (version === 'write') writeText(cwd, VERSION_FILE, versionText);
514
+ if (manifestState === 'write') writeText(cwd, MANIFEST_FILE, manifest);
515
+ return state;
516
+ }
517
+
518
+ function printDryState(s) {
519
+ const handoffWord = { create: 'create', replace: 'replace (old copy backed up)', unchanged: 'no change' }[s.handoff];
520
+ const word = (x) => (x === 'unchanged' ? 'no change' : 'write');
521
+ info('Would also:');
522
+ console.log(` - ${HANDOFF_FILE}: ${handoffWord}`);
523
+ console.log(` - ${VERSION_FILE}: ${word(s.version)}`);
524
+ console.log(` - ${MANIFEST_FILE}: ${word(s.manifest)}`);
525
+ if (s.config !== null) console.log(` - ${CONFIG_FILE}: ${word(s.config)} (selection persisted for later patch)`);
526
+ }
527
+
528
+ function printStale(stale) {
529
+ if (!stale.length) return;
530
+ warn(`${stale.length} stale toolkit file(s) left in place (not deleted; remove by hand if unwanted):`);
531
+ stale.forEach((f) => console.log(' - ' + f));
532
+ }
533
+
534
+ function printHandoff(env, state) {
199
535
  if (env && ENV_INFO[env]) {
200
536
  info(`Scaffold complete (tailored to ${ENV_INFO[env].name}). Hand off to your AI agent:`);
201
537
  console.log(` Bootstrap entry point: ${ENV_INFO[env].command}`);
@@ -208,41 +544,89 @@ function handoff(type, cwd, env) {
208
544
  console.log(' Other harnesses also have bootstrap commands; Codex uses the handoff file directly.');
209
545
  }
210
546
  console.log(' 2. Run `npx nitm-ai-dev-toolkit doctor' + (env ? ` --env ${env}` : '') + '` to verify the install.');
547
+ if (state.handoff === 'unchanged') info(`${HANDOFF_FILE} already up to date (not rewritten).`);
548
+ if (state.backup) info(`Previous ${HANDOFF_FILE} differed; backed up to ${state.backup}.`);
211
549
  }
212
550
 
551
+ // ---- Commands ----
552
+
213
553
  function cmdInstall(cwd, flags) {
214
554
  const type = detectType(cwd, flags);
215
555
  if (flags.env && !ENV_DIRS[flags.env]) warn(`Unknown --env "${flags.env}"; scaffolding all environments.`);
216
556
  info(`Detected project type: ${type}` + (flags.env ? `, environment: ${flags.env}` : ''));
217
- const cf = conflicts(type, cwd, flags.env);
218
- if (cf.length && !flags.force) {
219
- err(`Install aborted: ${cf.length} file(s) already exist in this project.`);
220
- cf.slice(0, 20).forEach((f) => console.log(' - ' + f));
221
- if (cf.length > 20) console.log(` ...and ${cf.length - 20} more.`);
557
+ const { selection, explicit } = resolveSelection(cwd, flags);
558
+ const plan = planFiles(type, cwd, { mode: 'install', force: flags.force, env: flags.env, selection });
559
+ const counts = countActions(plan);
560
+ const cf = plan.filter((e) => e.action === 'conflict');
561
+ const existing = cf.filter((e) => e.reason === 'exists');
562
+ const blocked = cf.filter((e) => e.reason !== 'exists');
563
+
564
+ if (flags.dryRun) {
565
+ printPlan(plan);
566
+ printDryState(recordState(cwd, { type, env: flags.env, plan, selection, explicit, dryRun: true }));
567
+ if (cf.length) process.exitCode = 1;
568
+ return { copied: 0, skipped: 0, conflicts: cf.length, dryRun: true, planned: counts };
569
+ }
570
+ if (blocked.length) {
571
+ err(`Install aborted: ${blocked.length} path(s) are blocked by a file or directory of another type.`);
572
+ blocked.slice(0, 20).forEach((e) => console.log(` - ${e.rel} (${e.reason})`));
573
+ throw new Error('install: path type conflicts block writes');
574
+ }
575
+ if (existing.length) {
576
+ err(`Install aborted: ${existing.length} file(s) already exist in this project.`);
577
+ existing.slice(0, 20).forEach((e) => console.log(' - ' + e.rel));
578
+ if (existing.length > 20) console.log(` ...and ${existing.length - 20} more.`);
222
579
  console.log('');
223
580
  info('To overwrite them, re-run with --force.');
224
581
  info('To keep your changes and only add missing files, run: npx nitm-ai-dev-toolkit patch');
225
582
  throw new Error('install: conflicting files exist (use --force to overwrite)');
226
583
  }
227
- const { copied, skipped } = copyMissing(type, cwd, flags.force, flags.env);
228
- ok(`Copied ${copied} file(s), skipped ${skipped} existing.`);
229
- handoff(type, cwd, flags.env);
230
- return { copied, skipped, conflicts: cf.length };
584
+ const copied = applyPlan(cwd, plan);
585
+ ok(`Copied ${copied} file(s), skipped ${counts.skip} existing.`);
586
+ printHandoff(flags.env, recordState(cwd, { type, env: flags.env, plan, selection, explicit, dryRun: false }));
587
+ return { copied, skipped: counts.skip, conflicts: 0, dryRun: false, planned: counts };
231
588
  }
232
589
 
233
590
  function cmdPatch(cwd, flags) {
234
591
  const type = detectType(cwd, flags);
592
+ const { selection, explicit } = resolveSelection(cwd, flags);
235
593
  // patch never overwrites existing files, even with --force (data-loss guard)
236
- const { copied, skipped } = copyMissing(type, cwd, false, flags.env);
237
- ok(`Ensured files present: ${copied} added, ${skipped} already there.`);
238
- handoff(type, cwd, flags.env);
239
- return { copied, skipped };
594
+ const plan = planFiles(type, cwd, { mode: 'patch', env: flags.env, selection });
595
+ const counts = countActions(plan);
596
+ const conflictEntries = plan.filter((e) => e.action === 'conflict');
597
+ const stale = findStale(cwd, type);
598
+
599
+ if (flags.dryRun) {
600
+ printPlan(plan);
601
+ printStale(stale);
602
+ printDryState(recordState(cwd, { type, env: flags.env, plan, selection, explicit, dryRun: true }));
603
+ if (conflictEntries.length) process.exitCode = 1;
604
+ return { copied: 0, skipped: counts.skip, conflicts: conflictEntries.length, stale, dryRun: true, planned: counts };
605
+ }
606
+ const copied = applyPlan(cwd, plan);
607
+ ok(`Ensured files present: ${copied} added, ${counts.skip} already there.`);
608
+ if (conflictEntries.length) {
609
+ err(`${conflictEntries.length} path(s) could not be written (blocked by a file or directory of another type):`);
610
+ conflictEntries.forEach((e) => console.log(` - ${e.rel} (${e.reason})`));
611
+ process.exitCode = 1;
612
+ }
613
+ printStale(stale);
614
+ printHandoff(flags.env, recordState(cwd, { type, env: flags.env, plan, selection, explicit, dryRun: false }));
615
+ return { copied, skipped: counts.skip, conflicts: conflictEntries.length, stale, dryRun: false, planned: counts };
240
616
  }
241
617
 
242
618
  // Placeholders the agent fills per task or feature at runtime, so they are not bootstrap leftovers.
243
619
  const RUNTIME_PLACEHOLDERS = new Set(['ISSUE_ID', 'ISSUE_KEY', 'FEATURE_NAME', 'NAME']);
244
- // `templates/` ships blank forms whose placeholders are meant to stay until a feature is written.
245
- const PLACEHOLDER_EXEMPT_DIR = 'templates';
620
+ // Placeholders are intentional in these: `templates/` and `docs/` ship blank forms and reference
621
+ // docs; bootstrap*.md, constitution.md and create-skill.md document placeholder syntax.
622
+ const PLACEHOLDER_EXEMPT_DIRS = ['templates', 'docs'];
623
+ const PLACEHOLDER_EXEMPT_FILES = [/^bootstrap.*\.md$/, /^constitution\.md$/, /^create-skill\.md$/];
624
+
625
+ function placeholderExempt(rel) {
626
+ const n = path.normalize(rel);
627
+ return PLACEHOLDER_EXEMPT_DIRS.includes(n.split(path.sep)[0])
628
+ || PLACEHOLDER_EXEMPT_FILES.some((re) => re.test(path.basename(n)));
629
+ }
246
630
 
247
631
  function hasUnresolvedPlaceholder(content) {
248
632
  const names = content.match(/\{\{[A-Z][A-Z0-9_]*\}\}/g) || [];
@@ -252,7 +636,7 @@ function hasUnresolvedPlaceholder(content) {
252
636
  function scanPlaceholders(cwd, relFiles) {
253
637
  const found = [];
254
638
  for (const rel of relFiles) {
255
- if (path.normalize(rel).split(path.sep)[0] === PLACEHOLDER_EXEMPT_DIR) continue;
639
+ if (placeholderExempt(rel)) continue;
256
640
  const p = path.join(cwd, rel);
257
641
  if (!fs.existsSync(p)) continue;
258
642
  try {
@@ -265,7 +649,10 @@ function scanPlaceholders(cwd, relFiles) {
265
649
 
266
650
  function cmdDoctor(cwd, flags) {
267
651
  const type = detectType(cwd, flags);
268
- const files = scopeFiles(templateFiles(type), flags.env).map(installName);
652
+ const { selection, explicit } = resolveSelection(cwd, flags);
653
+ const files = scopeFiles(templateFiles(type), flags.env)
654
+ .filter((rel) => selectionKeeps(rel, selection))
655
+ .map(installName);
269
656
  const missingFiles = [];
270
657
  let present = 0;
271
658
  for (const rel of files) {
@@ -273,7 +660,11 @@ function cmdDoctor(cwd, flags) {
273
660
  else missingFiles.push(rel);
274
661
  }
275
662
  const placeholders = scanPlaceholders(cwd, files);
276
- const result = { type, present, missingFiles, placeholders };
663
+ const version = toolkitVersionStatus(cwd);
664
+ const stale = findStale(cwd, type);
665
+ const customized = customizedOwned(cwd);
666
+ const result = { type, present, missingFiles, placeholders, version, stale, customized,
667
+ selection, selectionSource: explicit ? 'flags' : CONFIG_FILE };
277
668
 
278
669
  console.log('');
279
670
  if (missingFiles.length === 0) ok(`All ${present} template files present.`);
@@ -281,53 +672,165 @@ function cmdDoctor(cwd, flags) {
281
672
  warn(`${missingFiles.length} missing file(s):`);
282
673
  missingFiles.forEach((f) => console.log(' - ' + f));
283
674
  }
675
+ if (selection.exclude.length || selection.only.length) {
676
+ info(`Selection (${result.selectionSource}): exclude [${selection.exclude.join(', ')}], only [${selection.only.join(', ')}].`);
677
+ }
678
+ if (version.status === 'match') ok(`${VERSION_FILE} ${version.installed} matches toolkit ${version.package}.`);
679
+ else if (version.status === 'missing') warn(`${VERSION_FILE} is missing (toolkit ${version.package}). Run patch to record it.`);
680
+ else warn(`${VERSION_FILE} is ${version.installed}, toolkit is ${version.package}. Run upgrade or patch to record it.`);
284
681
  if (placeholders.length === 0) ok('No unresolved {{PLACEHOLDER}} values found.');
285
682
  else {
286
683
  warn(`${placeholders.length} file(s) still contain {{PLACEHOLDER}} values (AI handoff replaces these):`);
287
684
  placeholders.slice(0, 20).forEach((f) => console.log(' - ' + f));
288
685
  if (placeholders.length > 20) console.log(` ...and ${placeholders.length - 20} more.`);
289
686
  }
687
+ printStale(stale);
688
+ if (customized.length) info(`${customized.length} toolkit-installed file(s) differ from the install record (your edits; kept).`);
290
689
  console.log('');
291
690
  info('Run your AI agent on .nitm/BOOTSTRAP.md to finish configuration, then re-run doctor.');
292
691
  return result;
293
692
  }
294
693
 
295
- // Upgrade runs the latest package in patch mode: missing files are added and existing
694
+ // ---- Upgrade ----
695
+
696
+ // Semver-style ordering: X-anything sorts below X. Prerelease identifiers compare per semver
697
+ // (numeric identifiers numerically, below alphanumeric ones; a shorter set sorts first).
698
+ function compareVersions(a, b) {
699
+ const split = (v) => {
700
+ const i = v.indexOf('-');
701
+ return { core: (i < 0 ? v : v.slice(0, i)).split('.').map(Number), pre: i < 0 ? null : v.slice(i + 1) };
702
+ };
703
+ const x = split(a);
704
+ const y = split(b);
705
+ for (let i = 0; i < 3; i++) {
706
+ if (x.core[i] !== y.core[i]) return x.core[i] - y.core[i];
707
+ }
708
+ if (x.pre === y.pre) return 0;
709
+ if (x.pre === null) return 1;
710
+ if (y.pre === null) return -1;
711
+ const ax = x.pre.split('.');
712
+ const ay = y.pre.split('.');
713
+ for (let i = 0; i < Math.max(ax.length, ay.length); i++) {
714
+ if (ax[i] === undefined) return -1;
715
+ if (ay[i] === undefined) return 1;
716
+ const nx = /^\d+$/.test(ax[i]);
717
+ const ny = /^\d+$/.test(ay[i]);
718
+ if (nx && ny) {
719
+ if (Number(ax[i]) !== Number(ay[i])) return Number(ax[i]) - Number(ay[i]);
720
+ } else if (nx !== ny) {
721
+ return nx ? -1 : 1;
722
+ } else if (ax[i] !== ay[i]) {
723
+ return ax[i] < ay[i] ? -1 : 1;
724
+ }
725
+ }
726
+ return 0;
727
+ }
728
+
729
+ function currentVersion() {
730
+ try { return packageVersion(); } catch (_) { return 'unknown'; }
731
+ }
732
+
733
+ // `upgrade [version]`, `upgrade nitm-ai-dev-toolkit@<version>` or `upgrade --version <version>`.
734
+ // Returns undefined when no version was given (the latest published release is used).
735
+ function parseUpgradeTarget(flags) {
736
+ const positional = (flags._ || []).slice(1);
737
+ if (positional.length > 1) throw new Error(`Unknown option for upgrade: ${positional[1]}`);
738
+ if (positional.length && flags.version !== undefined) {
739
+ throw new Error('Give the version once: as an argument or with --version');
740
+ }
741
+ let v = flags.version !== undefined ? flags.version : positional[0];
742
+ if (v === undefined) return undefined;
743
+ if (v.startsWith(PKG_NAME + '@')) v = v.slice(PKG_NAME.length + 1);
744
+ if (!VERSION_RE.test(v)) {
745
+ throw new Error(`Pin an exact version such as 5.0.1 (got "${v}"). Leave it out to use the latest published release.`);
746
+ }
747
+ return v;
748
+ }
749
+
750
+ // `spec` is only ever `nitm-ai-dev-toolkit` or `nitm-ai-dev-toolkit@<version>`, and the version is
751
+ // checked by the anchored VERSION_RE before this runs (parseUpgradeTarget and resolveUpgradeVersion).
752
+ // That makes the Windows shell path safe: npm.cmd needs a shell, and nothing reaching it can carry
753
+ // shell metacharacters. Everywhere else the call uses argv with no shell.
754
+ function npmQuery(spec, field) {
755
+ const args = ['view', spec, field];
756
+ const opts = { encoding: 'utf8', timeout: 60000, stdio: 'pipe' };
757
+ try {
758
+ if (process.platform === 'win32') return execFileSync('npm.cmd', args, { ...opts, shell: true });
759
+ return execFileSync('npm', args, opts);
760
+ } catch (_) {
761
+ throw new Error('Could not reach npm to resolve the version (are you online?).');
762
+ }
763
+ }
764
+
765
+ // Resolves the exact published version to install. Never returns a range or `latest`.
766
+ function resolveUpgradeVersion(requested, npmView = npmQuery) {
767
+ // Validation comes first: no npm call may see a version that failed VERSION_RE.
768
+ if (requested) {
769
+ if (!VERSION_RE.test(requested)) throw new Error(`Pin an exact version such as 5.0.1 (got "${requested}").`);
770
+ const published = npmView(`${PKG_NAME}@${requested}`, 'version').split('\n')
771
+ .map((line) => line.trim().split(/\s+/).pop().replace(/^'|'$/g, ''));
772
+ if (!published.includes(requested)) throw new Error(`${PKG_NAME}@${requested} is not published on npm.`);
773
+ return requested;
774
+ }
775
+ const latest = npmView(PKG_NAME, 'dist-tags.latest').trim();
776
+ if (!VERSION_RE.test(latest)) throw new Error(`npm returned no usable latest version ("${latest}").`);
777
+ return latest;
778
+ }
779
+
780
+ // Upgrade runs the pinned package in patch mode: missing files are added and existing
296
781
  // (possibly customised) files are kept. Only an explicit --force reaches `install --force`.
297
- function upgradeArgs(flags) {
782
+ function upgradeArgs(flags, version) {
783
+ if (!version) throw new Error('upgradeArgs needs a resolved, pinned version');
298
784
  const force = Boolean(flags && flags.force);
299
- const args = ['--yes', 'nitm-ai-dev-toolkit@latest', force ? 'install' : 'patch'];
785
+ const args = ['--yes', `${PKG_NAME}@${version}`, force ? 'install' : 'patch'];
300
786
  if (force) args.push('--force');
301
787
  if (flags && flags.env) args.push('--env', flags.env);
788
+ if (flags && flags.exclude !== undefined) args.push('--exclude', flags.exclude.join(','));
789
+ if (flags && flags.only !== undefined) args.push('--only', flags.only.join(','));
790
+ if (flags && flags.dryRun) args.push('--dry-run');
302
791
  return args;
303
792
  }
304
793
 
305
- function cmdUpgrade(cwd, flags) {
306
- let latest;
307
- try {
308
- latest = execSync('npm view nitm-ai-dev-toolkit version', { encoding: 'utf8', timeout: 60000, stdio: 'pipe' }).trim();
309
- } catch (_) {
310
- err('Could not reach npm to check for updates (are you online?).');
311
- return;
794
+ function cmdUpgrade(cwd, flags, deps = {}) {
795
+ const requested = parseUpgradeTarget(flags);
796
+ // Reads the effective selection first: a corrupt saved config or a combined --exclude/--only stops here.
797
+ const { selection, explicit } = resolveSelection(cwd, flags);
798
+ const target = resolveUpgradeVersion(requested, deps.npmView || npmQuery);
799
+ info(`Resolved ${PKG_NAME}@${target} (installed toolkit: ${currentVersion()}).`);
800
+ const selectionActive = selection.exclude.length > 0 || selection.only.length > 0;
801
+ if (flags.dryRun && compareVersions(target, DRY_RUN_UPGRADE_MIN) < 0) {
802
+ throw new Error(`--dry-run needs ${PKG_NAME} >= ${DRY_RUN_UPGRADE_MIN}; ${target} has no dry run and would write. Pin a newer version.`);
312
803
  }
313
- let cur;
314
- try {
315
- cur = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8')).version;
316
- } catch (_) {
317
- cur = 'unknown';
804
+ if (compareVersions(target, DRY_RUN_UPGRADE_MIN) < 0 && selectionActive) {
805
+ const saved = explicit ? 'the --exclude/--only you passed' : `the selection saved in ${CONFIG_FILE}`;
806
+ throw new Error(`${PKG_NAME}@${target} predates --exclude/--only and would ignore ${saved}. `
807
+ + `Pin ${DRY_RUN_UPGRADE_MIN} or later, or clear the selection with --exclude "" (and no --only).`);
808
+ }
809
+ // An explicit empty selection clears the saved one. The pinned run cannot do this, so clear it here.
810
+ if (compareVersions(target, DRY_RUN_UPGRADE_MIN) < 0 && explicit && !flags.dryRun) {
811
+ const text = configText(selection);
812
+ if (writeState(path.join(cwd, CONFIG_FILE), text) === 'write') writeText(cwd, CONFIG_FILE, text);
318
813
  }
319
- if (latest === cur) info(`Already on latest version (${cur}); refreshing missing files and the handoff.`);
320
- else info(`New version available: ${latest} (current ${cur}).`);
321
- if (flags && flags.force) {
322
- warn('--force: existing files will be overwritten with the latest templates. Back up your customisations first.');
814
+ if (flags.force) warn('--force: existing files will be overwritten with the latest templates. Back up your customisations first.');
815
+ else info('Adding missing files only; your existing files are kept. Pass --force to overwrite them.');
816
+ info(`Running pinned ${PKG_NAME}@${target}${flags.dryRun ? ' (dry run, nothing written)' : ''}...`);
817
+ const run = deps.spawn || spawnSync;
818
+ const r = run('npx', upgradeArgs(flags, target), { cwd, stdio: 'inherit', timeout: 120000 });
819
+ if (r.error) {
820
+ err('Upgrade failed: ' + r.error.message);
821
+ process.exitCode = 1;
822
+ return { version: target, status: null };
823
+ }
824
+ if (r.status === 0) {
825
+ ok(flags.dryRun ? 'Dry run complete; nothing was written.' : 'Upgrade complete. Hand off to your AI agent with .nitm/BOOTSTRAP.md to finish setup.');
826
+ } else if (flags.dryRun && r.status === 1) {
827
+ err('Upgrade dry run found conflicts (exit 1); nothing was written. Resolve them, then re-run.');
828
+ process.exitCode = 1;
323
829
  } else {
324
- info('Adding missing files only; your existing files are kept. Pass --force to overwrite them.');
830
+ err('Upgrade failed.');
831
+ process.exitCode = 1;
325
832
  }
326
- info('Upgrading via latest package...');
327
- const r = spawnSync('npx', upgradeArgs(flags), { cwd, stdio: 'inherit', timeout: 120000 });
328
- if (r.error) { err('Upgrade failed: ' + r.error.message); return; }
329
- if (r.status === 0) ok('Upgrade complete. Hand off to your AI agent with .nitm/BOOTSTRAP.md to finish setup.');
330
- else err('Upgrade failed.');
833
+ return { version: target, status: r.status };
331
834
  }
332
835
 
333
836
  function cmdOmoStarter(cwd, flags) {
@@ -336,6 +839,10 @@ function cmdOmoStarter(cwd, flags) {
336
839
  err('Usage: nitm-ai-dev-toolkit omo-slim-starter install');
337
840
  return;
338
841
  }
842
+ if (flags.dryRun) {
843
+ info(`Dry run: would run \`npx --yes nitm-opencode-starter install\` in ${cwd}. Nothing was run.`);
844
+ return;
845
+ }
339
846
  info('Delegating to the published starter: npx nitm-opencode-starter install');
340
847
  const r = spawnSync('npx', ['--yes', 'nitm-opencode-starter', 'install'], { cwd, stdio: 'inherit', timeout: 120000 });
341
848
  if (r.error) { err('Could not run starter: ' + r.error.message); return; }
@@ -391,17 +898,23 @@ function parseFlags(argv) {
391
898
  else if (a === '--yes') flags.yes = true;
392
899
  else if (a === '--abandoned') flags.abandoned = true;
393
900
  else if (a === '--help' || a === '-h') flags.help = true;
394
- else if (a === '--dir' || a === '--interval') {
901
+ else if (a === '--dir' || a === '--interval' || a === '--version') {
395
902
  const v = argv[++i];
396
903
  if (v === undefined || v.startsWith('--')) throw new Error(`${a} requires a value`);
397
904
  flags[a.slice(2)] = v;
398
- } else if (a.startsWith('--dir=') || a.startsWith('--interval=')) {
905
+ } else if (a.startsWith('--dir=') || a.startsWith('--interval=') || a.startsWith('--version=')) {
399
906
  const eq = a.indexOf('=');
400
907
  const v = a.slice(eq + 1);
401
908
  if (!v) throw new Error(`${a.slice(0, eq)}= requires a value`);
402
909
  flags[a.slice(2, eq)] = v;
403
- }
404
- else if (a === '--env') {
910
+ } else if (a === '--exclude' || a === '--only') {
911
+ const v = argv[++i];
912
+ if (v === undefined || v.startsWith('--')) throw new Error(`${a} requires a value (e.g. ${a} factory,prisma)`);
913
+ flags[a.slice(2)] = splitGroups(v);
914
+ } else if (a.startsWith('--exclude=') || a.startsWith('--only=')) {
915
+ const eq = a.indexOf('=');
916
+ flags[a.slice(2, eq)] = splitGroups(a.slice(eq + 1));
917
+ } else if (a === '--env') {
405
918
  const v = argv[++i];
406
919
  if (v === undefined || v.startsWith('--')) throw new Error('--env requires a value (e.g. --env claude)');
407
920
  flags.env = v;
@@ -419,23 +932,28 @@ function usage() {
419
932
  console.log(`nitm-ai-dev-toolkit — scaffold AI agent config into any repo, then hand off to your AI agent.
420
933
 
421
934
  Usage:
422
- npx nitm-ai-dev-toolkit install [--monorepo|--repo] [--env <claude|codex|copilot|cursor|opencode>] [--force]
935
+ npx nitm-ai-dev-toolkit install [--monorepo|--repo] [--env <claude|codex|copilot|cursor|opencode>] [--force] [--dry-run] [--exclude <groups>|--only <groups>]
423
936
  Scaffold AI config into the current repo (auto-detects repo vs monorepo),
424
937
  tailored to your harness, then hand off to your AI agent to run the bootstrap flow.
425
938
  If files already exist, install aborts (use --force to overwrite, or \`patch\` to add missing).
939
+ --dry-run lists adds, overwrites, skips and conflicts; writes nothing; exits 1 if there are conflicts.
426
940
 
427
- npx nitm-ai-dev-toolkit patch [--monorepo|--repo] [--env <env>]
941
+ npx nitm-ai-dev-toolkit patch [--monorepo|--repo] [--env <env>] [--exclude <groups>|--only <groups>] [--dry-run]
428
942
  Add only missing template files (never overwrites existing ones), then
429
943
  re-emit the bootstrap handoff so your AI agent can finish configuration.
944
+ A given --exclude/--only is saved to .nitm/toolkit.json and used by later patch/doctor runs.
430
945
 
431
- npx nitm-ai-dev-toolkit upgrade [--env <env>] [--force]
432
- Upgrade to the latest published version. Like \`patch\`, adds missing files and
433
- keeps your existing ones, then re-emits the bootstrap handoff. --force also
434
- overwrites existing files with the latest templates (back up customisations first).
946
+ npx nitm-ai-dev-toolkit upgrade [version] [--env <env>] [--force] [--dry-run] [--exclude <groups>|--only <groups>]
947
+ Upgrade to a pinned version: the given one (\`upgrade 5.0.1\`, \`upgrade nitm-ai-dev-toolkit@5.0.1\`,
948
+ or \`--version 5.0.1\`) or the latest published release, resolved and printed before anything runs.
949
+ Like \`patch\`, adds missing files and keeps your existing ones, then re-emits the bootstrap handoff.
950
+ --force also overwrites existing files with the latest templates (back up customisations first).
951
+ --dry-run runs the pinned version's dry run (needs ${DRY_RUN_UPGRADE_MIN} or newer).
435
952
 
436
953
  npx nitm-ai-dev-toolkit doctor [--monorepo|--repo] [--env <env>]
437
954
  Inspect the current install: missing files, unresolved {{PLACEHOLDER}}
438
- values.
955
+ values, .toolkit-version against the toolkit version, and stale toolkit files
956
+ (reported, never deleted).
439
957
 
440
958
  npx nitm-ai-dev-toolkit omo-slim-starter install
441
959
  Run \`npx nitm-opencode-starter install\` to scaffold the oh-my-opencode-slim
@@ -473,9 +991,25 @@ Options:
473
991
  Ask your agent to read and follow .nitm/BOOTSTRAP.md.
474
992
  Other harnesses also have bootstrap commands; Codex uses the handoff
475
993
  file directly (no native /bootstrap command is provided).
994
+ --exclude <groups>
995
+ Comma-separated groups to leave out (install, patch, upgrade, doctor).
996
+ Harnesses: claude | codex | copilot | cursor | opencode.
997
+ Features: factory | prisma | bkt-cli | gh-cli | glab-cli | acli | linear-cli.
998
+ Harness groups keep the shared files (AGENTS.md, docs/, hooks/, shared skills
999
+ and snippets). Feature groups apply everywhere, including factory/ and shared
1000
+ skills such as .agents/skills/gh-cli. Pass --exclude "" to clear the saved selection.
1001
+ Cannot be combined with --only.
1002
+ --only <groups>
1003
+ Keep only the listed groups. Untagged shared files are still kept, as with --exclude.
1004
+ Same group names as --exclude. Cannot be combined with --exclude.
476
1005
  --monorepo Force monorepo template
477
1006
  --repo Force single-repo template
478
1007
  --force Overwrite existing files (install, upgrade). patch never overwrites
1008
+ --dry-run install/patch/upgrade: list what would change, write nothing.
1009
+ omo-slim-starter install: print the command only
1010
+ factory archive/prune: list only, change nothing
1011
+ --version <v>
1012
+ upgrade: pinned version to install (exact, e.g. 5.0.1)
479
1013
  --dir <path> factory: jobs directory (default: ./factory/jobs)
480
1014
  --interval <ms>
481
1015
  factory watch: refresh interval (default: 2000, minimum: 100)
@@ -483,7 +1017,6 @@ Options:
483
1017
  factory archive: include every closed job, not just those older than 7 days
484
1018
  factory prune: include every merged job, not just those older than 7 days
485
1019
  --abandoned factory prune: also delete abandoned jobs (default: merged only)
486
- --dry-run factory archive/prune: list only, change nothing
487
1020
  --yes factory prune: actually delete (without it, prune only lists)
488
1021
  -h, --help Show this help
489
1022
  `);
@@ -522,19 +1055,33 @@ module.exports = {
522
1055
  scopeFiles,
523
1056
  templateFiles,
524
1057
  installName,
525
- copyMissing,
1058
+ planFiles,
1059
+ conflicts,
526
1060
  scanPlaceholders,
527
1061
  envHeader,
528
- conflicts,
529
1062
  parseFlags,
1063
+ selectionKeeps,
1064
+ normalizeSelection,
1065
+ findStale,
1066
+ toolkitVersionStatus,
1067
+ readManifest,
530
1068
  cmdInstall,
531
1069
  cmdPatch,
532
1070
  cmdDoctor,
533
1071
  cmdUpgrade,
534
1072
  upgradeArgs,
1073
+ parseUpgradeTarget,
1074
+ resolveUpgradeVersion,
1075
+ compareVersions,
1076
+ DRY_RUN_UPGRADE_MIN,
535
1077
  cmdOmoStarter,
536
1078
  cmdFactory,
537
1079
  ENV_DIRS,
538
1080
  SHARED_ROOT,
539
1081
  SHARED_DIRS,
1082
+ KNOWN_HISTORICAL,
1083
+ VERSION_FILE,
1084
+ HANDOFF_FILE,
1085
+ CONFIG_FILE,
1086
+ MANIFEST_FILE,
540
1087
  };