nitm-ai-dev-toolkit 4.0.0 → 5.0.1

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