@open-mercato/cli 0.6.7-develop.6685.1.77c0a5591b → 0.6.7-develop.6687.1.46a258e592

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 (215) hide show
  1. package/.turbo/turbo-build.log +2 -3
  2. package/build.mjs +21 -5
  3. package/dist/agentic/claude-code/hooks/entity-migration-check.ts +32 -88
  4. package/dist/agentic/codex/enforcement-rules.md +2 -16
  5. package/dist/agentic/cursor/hooks/entity-migration-check.mjs +19 -50
  6. package/dist/agentic/cursor/rules/entity-guard.mdc +9 -21
  7. package/dist/agentic/cursor/rules/generated-guard.mdc +3 -5
  8. package/dist/agentic/cursor/rules/open-mercato.mdc +3 -29
  9. package/dist/agentic/guides/ai-workflows.md +76 -0
  10. package/dist/agentic/guides/architecture.md +107 -0
  11. package/dist/agentic/guides/backend-ui.md +80 -0
  12. package/dist/agentic/guides/contracts.md +92 -0
  13. package/dist/agentic/guides/extensions.md +104 -0
  14. package/dist/agentic/guides/integrations.md +81 -0
  15. package/dist/agentic/guides/module-facts.json +217 -81
  16. package/dist/agentic/guides/module-system.md +12 -127
  17. package/dist/agentic/guides/modules/ai_assistant.md +1 -1
  18. package/dist/agentic/guides/modules/api_docs.md +1 -1
  19. package/dist/agentic/guides/modules/api_keys.md +1 -1
  20. package/dist/agentic/guides/modules/attachments.md +2 -2
  21. package/dist/agentic/guides/modules/audit_logs.md +2 -2
  22. package/dist/agentic/guides/modules/auth.md +1 -1
  23. package/dist/agentic/guides/modules/business_rules.md +1 -1
  24. package/dist/agentic/guides/modules/catalog.md +5 -5
  25. package/dist/agentic/guides/modules/channel_gmail.md +1 -1
  26. package/dist/agentic/guides/modules/channel_imap.md +1 -1
  27. package/dist/agentic/guides/modules/checkout.md +1 -1
  28. package/dist/agentic/guides/modules/communication_channels.md +1 -1
  29. package/dist/agentic/guides/modules/configs.md +1 -1
  30. package/dist/agentic/guides/modules/content.md +1 -1
  31. package/dist/agentic/guides/modules/currencies.md +1 -1
  32. package/dist/agentic/guides/modules/customer_accounts.md +1 -1
  33. package/dist/agentic/guides/modules/customers.md +2 -2
  34. package/dist/agentic/guides/modules/dashboards.md +1 -1
  35. package/dist/agentic/guides/modules/data_sync.md +1 -1
  36. package/dist/agentic/guides/modules/dictionaries.md +1 -1
  37. package/dist/agentic/guides/modules/directory.md +1 -1
  38. package/dist/agentic/guides/modules/entities.md +2 -2
  39. package/dist/agentic/guides/modules/events.md +1 -1
  40. package/dist/agentic/guides/modules/feature_toggles.md +1 -1
  41. package/dist/agentic/guides/modules/gateway_stripe.md +1 -1
  42. package/dist/agentic/guides/modules/generators.md +1 -1
  43. package/dist/agentic/guides/modules/inbox_ops.md +1 -1
  44. package/dist/agentic/guides/modules/integrations.md +1 -1
  45. package/dist/agentic/guides/modules/messages.md +1 -1
  46. package/dist/agentic/guides/modules/notifications.md +1 -1
  47. package/dist/agentic/guides/modules/onboarding.md +1 -1
  48. package/dist/agentic/guides/modules/payment_gateways.md +1 -1
  49. package/dist/agentic/guides/modules/perspectives.md +1 -1
  50. package/dist/agentic/guides/modules/planner.md +1 -1
  51. package/dist/agentic/guides/modules/portal.md +1 -1
  52. package/dist/agentic/guides/modules/progress.md +1 -1
  53. package/dist/agentic/guides/modules/query_index.md +2 -2
  54. package/dist/agentic/guides/modules/record_locks.md +1 -1
  55. package/dist/agentic/guides/modules/resources.md +1 -1
  56. package/dist/agentic/guides/modules/sales.md +17 -17
  57. package/dist/agentic/guides/modules/scheduler.md +1 -1
  58. package/dist/agentic/guides/modules/search.md +1 -1
  59. package/dist/agentic/guides/modules/security.md +1 -1
  60. package/dist/agentic/guides/modules/shipping_carriers.md +1 -1
  61. package/dist/agentic/guides/modules/sso.md +1 -1
  62. package/dist/agentic/guides/modules/staff.md +2 -2
  63. package/dist/agentic/guides/modules/storage_s3.md +1 -1
  64. package/dist/agentic/guides/modules/sync_akeneo.md +1 -1
  65. package/dist/agentic/guides/modules/sync_excel.md +1 -1
  66. package/dist/agentic/guides/modules/system_status_overlays.md +1 -1
  67. package/dist/agentic/guides/modules/translations.md +1 -1
  68. package/dist/agentic/guides/modules/webhooks.md +2 -2
  69. package/dist/agentic/guides/modules/wms.md +2 -2
  70. package/dist/agentic/guides/modules/workflows.md +1 -1
  71. package/dist/agentic/guides/testing-debugging.md +74 -0
  72. package/dist/agentic/guides/upstream/AGENTS.md +297 -0
  73. package/dist/agentic/guides/upstream/BACKWARD_COMPATIBILITY.md +303 -0
  74. package/dist/agentic/guides/upstream/manifest.json +8 -0
  75. package/dist/agentic/shared/AGENTS.md.template +123 -311
  76. package/dist/agentic/shared/ai/agentic.config.json +1 -0
  77. package/dist/agentic/shared/ai/harness/README.md +48 -0
  78. package/dist/agentic/shared/ai/harness/RELEASE.md +57 -0
  79. package/dist/agentic/shared/ai/harness/cases.json +13487 -0
  80. package/dist/agentic/shared/ai/harness/cases.schema.json +132 -0
  81. package/dist/agentic/shared/ai/harness/fixtures/index.json +49 -0
  82. package/dist/agentic/shared/ai/harness/fixtures/seeds.json +162 -0
  83. package/dist/agentic/shared/ai/harness/generated-code-review-policy.md +20 -0
  84. package/dist/agentic/shared/ai/harness/generated-code-review-response.schema.json +43 -0
  85. package/dist/agentic/shared/ai/harness/generated-code-review-result.schema.json +110 -0
  86. package/dist/agentic/shared/ai/harness/release-matrix.json +133 -0
  87. package/dist/agentic/shared/ai/harness/release-result.schema.json +219 -0
  88. package/dist/agentic/shared/ai/harness/result.schema.json +76 -0
  89. package/dist/agentic/shared/ai/harness/routing-response.schema.json +12 -0
  90. package/dist/agentic/shared/ai/harness/target-validation-result.schema.json +73 -0
  91. package/dist/agentic/shared/ai/harness/validators.json +78 -0
  92. package/dist/agentic/shared/ai/harness/writable-ast-oracles.mjs +1768 -0
  93. package/dist/agentic/shared/ai/harness/writable-behavior-oracles.mjs +1096 -0
  94. package/dist/agentic/shared/ai/review-checklist.md +36 -0
  95. package/dist/agentic/shared/ai/skills/om-auto-review-pr/SKILL.md +4 -0
  96. package/dist/agentic/shared/ai/skills/om-backend-ui-design/SKILL.md +20 -241
  97. package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/crud-surfaces.md +16 -0
  98. package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/frontend-and-design-system.md +53 -0
  99. package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/page-and-navigation.md +13 -0
  100. package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/quality-states.md +11 -0
  101. package/dist/agentic/shared/ai/skills/om-build-workflow/SKILL.md +30 -0
  102. package/dist/agentic/shared/ai/skills/om-build-workflow/references/activity-contracts.md +12 -0
  103. package/dist/agentic/shared/ai/skills/om-build-workflow/references/durability-and-progress.md +13 -0
  104. package/dist/agentic/shared/ai/skills/om-build-workflow/references/workflow-design.md +14 -0
  105. package/dist/agentic/shared/ai/skills/om-create-ai-agent/SKILL.md +28 -0
  106. package/dist/agentic/shared/ai/skills/om-create-ai-agent/references/attachments-and-overrides.md +12 -0
  107. package/dist/agentic/shared/ai/skills/om-create-ai-agent/references/module-agents-and-tools.md +22 -0
  108. package/dist/agentic/shared/ai/skills/om-create-ai-agent/references/orchestrator-agents.md +12 -0
  109. package/dist/agentic/shared/ai/skills/om-create-ai-agent/references/surface-selector.md +19 -0
  110. package/dist/agentic/shared/ai/skills/om-data-model-design/SKILL.md +16 -601
  111. package/dist/agentic/shared/ai/skills/om-data-model-design/references/integrity-and-concurrency.md +19 -0
  112. package/dist/agentic/shared/ai/skills/om-data-model-design/references/migration-workflow.md +13 -0
  113. package/dist/agentic/shared/ai/skills/om-data-model-design/references/schema-design.md +14 -0
  114. package/dist/agentic/shared/ai/skills/om-data-model-design/references/sensitive-data.md +10 -0
  115. package/dist/agentic/shared/ai/skills/om-eject-and-customize/SKILL.md +14 -310
  116. package/dist/agentic/shared/ai/skills/om-eject-and-customize/references/decision-and-procedure.md +17 -0
  117. package/dist/agentic/shared/ai/skills/om-evolve-harness/SKILL.md +28 -0
  118. package/dist/agentic/shared/ai/skills/om-evolve-harness/references/case-template.md +89 -0
  119. package/dist/agentic/shared/ai/skills/om-evolve-harness/references/case-workflow.md +16 -0
  120. package/dist/agentic/shared/ai/skills/om-evolve-harness/references/owner-selection.md +17 -0
  121. package/dist/agentic/shared/ai/skills/om-framework-context/SKILL.md +25 -0
  122. package/dist/agentic/shared/ai/skills/om-framework-context/references/resolver-procedure.md +11 -0
  123. package/dist/agentic/shared/ai/skills/om-framework-context/references/skew-and-escalation.md +13 -0
  124. package/dist/agentic/shared/ai/skills/om-help/SKILL.md +15 -97
  125. package/dist/agentic/shared/ai/skills/om-help/references/delivery-workflows.md +17 -0
  126. package/dist/agentic/shared/ai/skills/om-help/references/task-families.md +20 -0
  127. package/dist/agentic/shared/ai/skills/om-implement-spec/SKILL.md +15 -156
  128. package/dist/agentic/shared/ai/skills/om-implement-spec/references/phases-and-gates.md +12 -0
  129. package/dist/agentic/shared/ai/skills/om-integration-builder/SKILL.md +24 -708
  130. package/dist/agentic/shared/ai/skills/om-integration-builder/references/package-and-activation.md +16 -0
  131. package/dist/agentic/shared/ai/skills/om-integration-builder/references/provider-families.md +14 -0
  132. package/dist/agentic/shared/ai/skills/om-integration-builder/references/security-and-reliability.md +15 -0
  133. package/dist/agentic/shared/ai/skills/om-integration-builder/references/sync-and-files.md +12 -0
  134. package/dist/agentic/shared/ai/skills/om-module-scaffold/SKILL.md +20 -820
  135. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/api-and-domain.md +19 -0
  136. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/business-one-shot-blueprints.md +107 -0
  137. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/data-and-migrations.md +12 -0
  138. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/discovery-surface-catalog.md +83 -0
  139. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/module-surfaces.md +21 -0
  140. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/planning.md +11 -0
  141. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/runtime-cache-and-queues.md +22 -0
  142. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/verification.md +13 -0
  143. package/dist/agentic/shared/ai/skills/om-system-extension/SKILL.md +19 -843
  144. package/dist/agentic/shared/ai/skills/om-system-extension/references/extension-branches.md +18 -0
  145. package/dist/agentic/shared/ai/skills/om-system-extension/references/mechanism-selector.md +25 -0
  146. package/dist/agentic/shared/ai/skills/om-system-extension/references/read-write-roundtrip.md +13 -0
  147. package/dist/agentic/shared/ai/skills/om-system-extension/references/unified-overrides.md +38 -0
  148. package/dist/agentic/shared/ai/skills/om-system-extension/references/verification.md +11 -0
  149. package/dist/agentic/shared/ai/skills/om-trim-unused-modules/SKILL.md +15 -63
  150. package/dist/agentic/shared/ai/skills/om-trim-unused-modules/references/dependency-and-redirect.md +15 -0
  151. package/dist/agentic/shared/ai/skills/om-troubleshooter/SKILL.md +16 -467
  152. package/dist/agentic/shared/ai/skills/om-troubleshooter/references/diagnosis-map.md +16 -0
  153. package/dist/agentic/shared/ai/skills/om-troubleshooter/references/regression-oracles.md +14 -0
  154. package/dist/agentic/shared/ai/skills/tiers.json +137 -20
  155. package/dist/agentic/shared/ai/skills/tiers.schema.json +52 -40
  156. package/dist/agentic/shared/scripts/agent-harness-tool-server.mjs +174 -0
  157. package/dist/agentic/shared/scripts/evaluate-agent-harness.mjs +2783 -0
  158. package/dist/agentic/shared/scripts/execution-sandbox.mjs +329 -0
  159. package/dist/agentic/shared/scripts/framework-context.mjs +894 -0
  160. package/dist/agentic/shared/scripts/install-skills.mjs +1029 -0
  161. package/dist/agentic/shared/scripts/install-skills.sh +4 -694
  162. package/dist/agentic/shared/scripts/prepare-agent-harness-fixture.mjs +222 -0
  163. package/dist/agentic/shared/scripts/run-agent-harness-release.mjs +1761 -0
  164. package/dist/lib/__integration__/TC-INT-008.spec.js +87 -8
  165. package/dist/lib/__integration__/TC-INT-008.spec.js.map +2 -2
  166. package/dist/lib/agentic-init.js +13 -3
  167. package/dist/lib/agentic-init.js.map +2 -2
  168. package/dist/lib/agentic-setup.js +418 -186
  169. package/dist/lib/agentic-setup.js.map +2 -2
  170. package/dist/lib/generators/extensions/injection-widgets.js +20 -0
  171. package/dist/lib/generators/extensions/injection-widgets.js.map +2 -2
  172. package/dist/lib/generators/module-facts-discovery.js +41 -7
  173. package/dist/lib/generators/module-facts-discovery.js.map +2 -2
  174. package/dist/lib/generators/module-facts.js +65 -17
  175. package/dist/lib/generators/module-facts.js.map +2 -2
  176. package/dist/lib/generators/module-registry.js +5 -4
  177. package/dist/lib/generators/module-registry.js.map +2 -2
  178. package/dist/lib/resolver.js +2 -0
  179. package/dist/lib/resolver.js.map +2 -2
  180. package/package.json +5 -5
  181. package/src/lib/__integration__/TC-INT-008.spec.ts +103 -8
  182. package/src/lib/__tests__/agentic-init.test.ts +41 -5
  183. package/src/lib/__tests__/agentic-setup.ownership.test.ts +380 -0
  184. package/src/lib/__tests__/injection-widget-id-validation.test.ts +184 -0
  185. package/src/lib/agentic-init.ts +15 -3
  186. package/src/lib/agentic-setup.ts +526 -224
  187. package/src/lib/generators/__tests__/module-facts.bc-guard.test.ts +6 -0
  188. package/src/lib/generators/__tests__/module-facts.customers.fixture.test.ts +1 -0
  189. package/src/lib/generators/__tests__/module-facts.discovery.test.ts +54 -10
  190. package/src/lib/generators/__tests__/module-facts.extension-hosts.test.ts +108 -0
  191. package/src/lib/generators/__tests__/module-subset.test.ts +18 -0
  192. package/src/lib/generators/extensions/injection-widgets.ts +51 -0
  193. package/src/lib/generators/module-facts-discovery.ts +58 -7
  194. package/src/lib/generators/module-facts.ts +84 -20
  195. package/src/lib/generators/module-registry.ts +8 -4
  196. package/src/lib/resolver.ts +3 -0
  197. package/dist/agentic/guides/cache.md +0 -45
  198. package/dist/agentic/guides/core.md +0 -453
  199. package/dist/agentic/guides/events.md +0 -117
  200. package/dist/agentic/guides/queue.md +0 -53
  201. package/dist/agentic/guides/search.md +0 -115
  202. package/dist/agentic/guides/shared.md +0 -108
  203. package/dist/agentic/guides/ui.md +0 -347
  204. package/dist/agentic/shared/ai/skills/om-auto-continue-pr-loop/SKILL.md +0 -27
  205. package/dist/agentic/shared/ai/skills/om-auto-create-pr-loop/SKILL.md +0 -27
  206. package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/ui-components.md +0 -260
  207. package/dist/agentic/shared/ai/skills/om-data-model-design/references/mikro-orm-cheatsheet.md +0 -146
  208. package/dist/agentic/shared/ai/skills/om-help/references/skills-catalog.md +0 -84
  209. package/dist/agentic/shared/ai/skills/om-help/references/workflow-sequences.md +0 -193
  210. package/dist/agentic/shared/ai/skills/om-integration-builder/STANDALONE.md +0 -34
  211. package/dist/agentic/shared/ai/skills/om-integration-builder/references/adapter-contracts.md +0 -762
  212. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/naming-conventions.md +0 -50
  213. package/dist/agentic/shared/ai/skills/om-module-scaffold/references/navigation-patterns.md +0 -97
  214. package/dist/agentic/shared/ai/skills/om-system-extension/references/extension-contracts.md +0 -271
  215. package/dist/agentic/shared/ai/skills/om-troubleshooter/references/diagnostic-commands.md +0 -101
@@ -6,16 +6,35 @@
6
6
  * This module reads those files at runtime — no embedded string constants.
7
7
  */
8
8
 
9
- import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, symlinkSync, lstatSync, unlinkSync, readdirSync } from 'node:fs'
9
+ import {
10
+ chmodSync,
11
+ copyFileSync,
12
+ existsSync,
13
+ lstatSync,
14
+ mkdirSync,
15
+ mkdtempSync,
16
+ readFileSync,
17
+ readdirSync,
18
+ realpathSync,
19
+ renameSync,
20
+ rmSync,
21
+ statSync,
22
+ writeFileSync,
23
+ } from 'node:fs'
24
+ import { createHash } from 'node:crypto'
10
25
  import { spawnSync } from 'node:child_process'
11
- import { join, dirname, basename } from 'node:path'
26
+ import { tmpdir } from 'node:os'
27
+ import { join, dirname, basename, relative, resolve, sep } from 'node:path'
12
28
  import { fileURLToPath } from 'node:url'
13
29
  import { Project, SyntaxKind } from 'ts-morph'
14
30
 
15
- const __dirname = dirname(fileURLToPath(import.meta.url))
16
- // In the built output (dist/lib/agentic-setup.js), __dirname is dist/lib/.
31
+ const moduleDir = dirname(fileURLToPath(import.meta.url))
32
+ // In the built output (dist/lib/agentic-setup.js), moduleDir is dist/lib/.
17
33
  // agentic/ is copied to dist/agentic/ by build.mjs.
18
- const AGENTIC_DIR = join(__dirname, '..', 'agentic')
34
+ const bundledAgenticDir = join(moduleDir, '..', 'agentic')
35
+ const AGENTIC_DIR = existsSync(bundledAgenticDir)
36
+ ? bundledAgenticDir
37
+ : join(moduleDir, '..', '..', '..', 'create-app', 'agentic')
19
38
  const GUIDES_DIR = join(AGENTIC_DIR, 'guides')
20
39
 
21
40
  type AskFn = (question: string) => Promise<string>
@@ -23,6 +42,7 @@ type AskFn = (question: string) => Promise<string>
23
42
  interface AgenticSetupOptions {
24
43
  tool?: string
25
44
  force?: boolean
45
+ updateHarness?: boolean
26
46
  }
27
47
 
28
48
  interface AgenticConfig {
@@ -30,6 +50,19 @@ interface AgenticConfig {
30
50
  targetDir: string
31
51
  }
32
52
 
53
+ interface HarnessManifestFile {
54
+ path: string
55
+ sha256: string
56
+ source: string
57
+ userEditable: boolean
58
+ }
59
+
60
+ interface HarnessManifest {
61
+ version: number
62
+ generator: string
63
+ files: HarnessManifestFile[]
64
+ }
65
+
33
66
  // ─── Helpers ─────────────────────────────────────────────────────────────
34
67
 
35
68
  function resolvePlaceholders(content: string, config: AgenticConfig): string {
@@ -59,14 +92,14 @@ function copyFile(srcDir: string, srcRelative: string, destPath: string): void {
59
92
  // AST-parse the static `enabledModules` array literal in the app's src/modules.ts
60
93
  // and collect each entry's `id`. Only the static literal is read (conditional
61
94
  // .push()/spread entries are intentionally not seen — see spec D6).
62
- function readEnabledModuleIds(modulesPath: string): string[] {
63
- if (!existsSync(modulesPath)) return []
95
+ function readEnabledModuleIds(modulesPath: string): { parsed: boolean; ids: string[] } {
96
+ if (!existsSync(modulesPath)) return { parsed: false, ids: [] }
64
97
  try {
65
98
  const project = new Project({ useInMemoryFileSystem: true })
66
99
  const sourceFile = project.createSourceFile('modules.ts', readFileSync(modulesPath, 'utf-8'))
67
100
  const declaration = sourceFile.getVariableDeclaration('enabledModules')
68
101
  const arrayLiteral = declaration?.getInitializerIfKind(SyntaxKind.ArrayLiteralExpression)
69
- if (!arrayLiteral) return []
102
+ if (!arrayLiteral) return { parsed: false, ids: [] }
70
103
  const ids: string[] = []
71
104
  for (const element of arrayLiteral.getElements()) {
72
105
  const objectLiteral = element.asKind(SyntaxKind.ObjectLiteralExpression)
@@ -75,9 +108,9 @@ function readEnabledModuleIds(modulesPath: string): string[] {
75
108
  const idValue = idProperty?.getInitializerIfKind(SyntaxKind.StringLiteral)?.getLiteralValue()
76
109
  if (idValue) ids.push(idValue)
77
110
  }
78
- return ids
111
+ return { parsed: true, ids }
79
112
  } catch {
80
- return []
113
+ return { parsed: false, ids: [] }
81
114
  }
82
115
  }
83
116
 
@@ -92,48 +125,23 @@ function selectModuleFactSheets(targetDir: string, modulesSubdir: string): strin
92
125
  .map((file) => file.replace(/\.md$/, ''))
93
126
  : []
94
127
  if (available.length === 0) return []
95
- const enabled = new Set(readEnabledModuleIds(join(targetDir, 'src', 'modules.ts')))
128
+ const parsed = readEnabledModuleIds(join(targetDir, 'src', 'modules.ts'))
129
+ if (!parsed.parsed) return available
130
+ const enabled = new Set(parsed.ids)
96
131
  const selected = available.filter((moduleId) => enabled.has(moduleId))
97
- return selected.length > 0 ? selected : available
132
+ return selected
98
133
  }
99
134
 
100
135
  const MODULE_GUIDES_START = '<!-- om:module-guides:start -->'
101
136
  const MODULE_GUIDES_END = '<!-- om:module-guides:end -->'
102
137
 
103
- // Read each module's guide label from the bundled `module-facts.json` (emitted by
104
- // build.mjs from the generator's extraction of each module's own `metadata`). The
105
- // label falls back description → title → generic, so the CLI never re-declares
106
- // specific module names or descriptions. A missing/malformed sidecar degrades to an
107
- // empty map (generic labels), never a throw.
108
- function readModuleGuideLabels(guidesDir: string): Record<string, string> {
109
- const factsPath = join(guidesDir, 'module-facts.json')
110
- if (!existsSync(factsPath)) return {}
111
- try {
112
- const parsed = JSON.parse(readFileSync(factsPath, 'utf-8')) as Record<
113
- string,
114
- { description?: unknown; title?: unknown }
115
- >
116
- const labels: Record<string, string> = {}
117
- for (const [moduleId, entry] of Object.entries(parsed)) {
118
- const label =
119
- (entry && typeof entry.description === 'string' && entry.description) ||
120
- (entry && typeof entry.title === 'string' && entry.title) ||
121
- ''
122
- if (label) labels[moduleId] = label
123
- }
124
- return labels
125
- } catch {
126
- return {}
127
- }
128
- }
129
-
130
- function renderModuleGuidesBlock(selected: string[], labels: Record<string, string>): string {
138
+ export function renderModuleGuidesBlock(selected: string[]): string {
131
139
  if (selected.length === 0) return '_No module fact-sheets are bundled for this app._'
132
- const rows = selected.map((moduleId) => {
133
- const label = labels[moduleId] ?? `Use the ${moduleId} module`
134
- return `| ${label} | \`.ai/guides/modules/${moduleId}.md\` |`
135
- })
136
- return ['| Task | Load |', '|---|---|', ...rows].join('\n')
140
+ return [
141
+ `Enabled module facts: ${selected.map((moduleId) => `\`${moduleId}\``).join(',')}.`,
142
+ '',
143
+ 'Load `.ai/guides/modules/<id>.md` only when `<id>` is explicitly named or is the targeted installed module/host; never preload all module facts.',
144
+ ].join('\n')
137
145
  }
138
146
 
139
147
  // Regenerate the marker-delimited Module-Specific Guides block in the written
@@ -142,7 +150,6 @@ function renderModuleGuidesBlock(selected: string[], labels: Record<string, stri
142
150
  function injectModuleGuides(
143
151
  agentsMdPath: string,
144
152
  selected: string[],
145
- labels: Record<string, string> = {},
146
153
  ): void {
147
154
  if (!existsSync(agentsMdPath)) return
148
155
  const content = readFileSync(agentsMdPath, 'utf-8')
@@ -156,188 +163,424 @@ function injectModuleGuides(
156
163
  }
157
164
  const before = content.slice(0, startIndex + MODULE_GUIDES_START.length)
158
165
  const after = content.slice(endIndex)
159
- const next = `${before}\n${renderModuleGuidesBlock(selected, labels)}\n${after}`
166
+ const next = `${before}\n${renderModuleGuidesBlock(selected)}\n${after}`
160
167
  if (next !== content) writeFileSync(agentsMdPath, next)
161
168
  }
162
169
 
163
- // ─── Generators ──────────────────────────────────────────────────────────
170
+ const TEXT_EXTENSIONS = new Set(['.cjs', '.json', '.md', '.mdc', '.mjs', '.sh', '.ts', '.txt'])
164
171
 
165
- function generateShared(config: AgenticConfig): void {
166
- const { targetDir } = config
167
- const srcDir = join(AGENTIC_DIR, 'shared')
172
+ function isTextAsset(path: string): boolean {
173
+ const dot = path.lastIndexOf('.')
174
+ return dot === -1 || TEXT_EXTENSIONS.has(path.slice(dot))
175
+ }
168
176
 
169
- // Resolve which per-module fact-sheets this app gets (enabled ∩ bundled allowlist).
170
- const selectedModules = selectModuleFactSheets(targetDir, join(GUIDES_DIR, 'modules'))
171
- const moduleGuideLabels = readModuleGuideLabels(GUIDES_DIR)
177
+ function listFiles(root: string): string[] {
178
+ if (!existsSync(root)) return []
179
+ const files: string[] = []
180
+ for (const entry of readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
181
+ const absolute = join(root, entry.name)
182
+ if (entry.isDirectory()) files.push(...listFiles(absolute))
183
+ else if (entry.isFile()) files.push(absolute)
184
+ }
185
+ return files
186
+ }
172
187
 
173
- // AGENTS.md
174
- writeTemplate(srcDir, 'AGENTS.md.template', join(targetDir, 'AGENTS.md'), config)
175
- injectModuleGuides(join(targetDir, 'AGENTS.md'), selectedModules, moduleGuideLabels)
176
-
177
- // .ai/ structure
178
- writeTemplate(srcDir, 'ai/specs/README.md', join(targetDir, '.ai', 'specs', 'README.md'), config)
179
- copyFile(srcDir, 'ai/specs/SPEC-000-template.md', join(targetDir, '.ai', 'specs', 'SPEC-000-template.md'))
180
- copyFile(srcDir, 'ai/lessons.md', join(targetDir, '.ai', 'lessons.md'))
181
-
182
- // .ai/skills/ — the shared skills-mixin manifest + tracker + external installer.
183
- // Skills owned by the external open-mercato/skills collection (code review, spec
184
- // writing, integration tests, prepare-issue, the auto-* PR family) are NOT copied
185
- // here; the user installs them with `yarn install-skills` (npx skills add). Only
186
- // standalone-only + kept-local skills and repo-local override folders are copied.
187
- copyFile(srcDir, 'ai/skills/tiers.json', join(targetDir, '.ai', 'skills', 'tiers.json'))
188
- copyFile(srcDir, 'ai/skills/tiers.schema.json', join(targetDir, '.ai', 'skills', 'tiers.schema.json'))
189
- copyFile(srcDir, 'ai/agentic.config.json', join(targetDir, '.ai', 'agentic.config.json'))
190
- copyFile(srcDir, 'ai/trackers/github.md', join(targetDir, '.ai', 'trackers', 'github.md'))
191
- copyFile(srcDir, 'scripts/install-skills.sh', join(targetDir, 'scripts', 'install-skills.sh'))
192
-
193
- copyFile(
194
- srcDir,
195
- 'ai/skills/om-backend-ui-design/SKILL.md',
196
- join(targetDir, '.ai', 'skills', 'om-backend-ui-design', 'SKILL.md'),
197
- )
198
- copyFile(
199
- srcDir,
200
- 'ai/skills/om-backend-ui-design/references/ui-components.md',
201
- join(targetDir, '.ai', 'skills', 'om-backend-ui-design', 'references', 'ui-components.md'),
202
- )
203
- copyFile(srcDir, 'ai/skills/om-integration-builder/SKILL.md', join(targetDir, '.ai', 'skills', 'om-integration-builder', 'SKILL.md'))
204
- if (existsSync(join(srcDir, 'ai', 'skills', 'om-integration-builder', 'STANDALONE.md'))) {
205
- copyFile(
206
- srcDir,
207
- 'ai/skills/om-integration-builder/STANDALONE.md',
208
- join(targetDir, '.ai', 'skills', 'om-integration-builder', 'STANDALONE.md'),
209
- )
188
+ function copyTree(sourceRoot: string, destinationRoot: string, config: AgenticConfig): void {
189
+ for (const sourcePath of listFiles(sourceRoot)) {
190
+ const destinationPath = join(destinationRoot, relative(sourceRoot, sourcePath))
191
+ ensureDir(destinationPath)
192
+ if (isTextAsset(sourcePath)) {
193
+ writeFileSync(destinationPath, resolvePlaceholders(readFileSync(sourcePath, 'utf8'), config))
194
+ } else {
195
+ copyFileSync(sourcePath, destinationPath)
196
+ }
197
+ if (process.platform !== 'win32') chmodSync(destinationPath, statSync(sourcePath).mode & 0o777)
210
198
  }
211
- copyFile(
212
- srcDir,
213
- 'ai/skills/om-integration-builder/references/adapter-contracts.md',
214
- join(targetDir, '.ai', 'skills', 'om-integration-builder', 'references', 'adapter-contracts.md'),
215
- )
216
- if (existsSync(join(srcDir, 'ai', 'skills', 'om-integration-builder', 'STANDALONE.md'))) {
217
- copyFile(
218
- srcDir,
219
- 'ai/skills/om-integration-builder/STANDALONE.md',
220
- join(targetDir, '.ai', 'skills', 'om-integration-builder', 'STANDALONE.md'),
221
- )
199
+ }
200
+
201
+ function hashFile(path: string): string {
202
+ return createHash('sha256').update(readFileSync(path)).digest('hex')
203
+ }
204
+
205
+ function canonicalHarnessRoot(root: string): string {
206
+ const resolved = resolve(root)
207
+ if (!existsSync(resolved)) {
208
+ throw new Error(`Harness root must be an existing directory: ${resolved}`)
209
+ }
210
+ const canonical = realpathSync(resolved)
211
+ const entry = lstatSync(canonical, { throwIfNoEntry: false })
212
+ if (!entry || !entry.isDirectory()) {
213
+ throw new Error(`Harness root must be an existing directory: ${resolved}`)
222
214
  }
215
+ return canonical
216
+ }
223
217
 
224
- copyFile(
225
- srcDir,
226
- 'ai/skills/om-system-extension/SKILL.md',
227
- join(targetDir, '.ai', 'skills', 'om-system-extension', 'SKILL.md'),
228
- )
229
- copyFile(
230
- srcDir,
231
- 'ai/skills/om-system-extension/references/extension-contracts.md',
232
- join(targetDir, '.ai', 'skills', 'om-system-extension', 'references', 'extension-contracts.md'),
233
- )
218
+ /**
219
+ * Validate a managed path without following any link below the canonical app
220
+ * root. A lexical containment check alone is insufficient: `app/.ai -> /tmp`
221
+ * would otherwise make an apparently safe update write outside the app.
222
+ */
223
+ function assertManagedPath(root: string, path: string, options: { leaf?: 'file' | 'any' } = {}): void {
224
+ const absolutePath = resolve(path)
225
+ const normalizedRelative = relative(root, absolutePath)
226
+ if (
227
+ normalizedRelative === '..' ||
228
+ normalizedRelative.startsWith(`..${sep}`)
229
+ ) {
230
+ throw new Error(`Harness-managed path escapes its root: ${absolutePath}`)
231
+ }
232
+ if (normalizedRelative === '') return
233
+
234
+ const components = normalizedRelative.split(sep).filter(Boolean)
235
+ let current = root
236
+ for (let index = 0; index < components.length; index += 1) {
237
+ current = join(current, components[index])
238
+ const entry = lstatSync(current, { throwIfNoEntry: false })
239
+ if (!entry) return
240
+ if (entry.isSymbolicLink()) {
241
+ throw new Error(`Harness-managed path contains a symbolic-link component: ${current}`)
242
+ }
243
+ const isLeaf = index === components.length - 1
244
+ if (!isLeaf && !entry.isDirectory()) {
245
+ throw new Error(`Harness-managed path component is not a directory: ${current}`)
246
+ }
247
+ if (isLeaf && options.leaf === 'file' && !entry.isFile()) {
248
+ throw new Error(`Harness-managed file is not a regular file: ${current}`)
249
+ }
250
+ }
251
+ }
234
252
 
235
- copyFile(
236
- srcDir,
237
- 'ai/skills/om-module-scaffold/SKILL.md',
238
- join(targetDir, '.ai', 'skills', 'om-module-scaffold', 'SKILL.md'),
239
- )
240
- copyFile(
241
- srcDir,
242
- 'ai/skills/om-module-scaffold/references/naming-conventions.md',
243
- join(targetDir, '.ai', 'skills', 'om-module-scaffold', 'references', 'naming-conventions.md'),
244
- )
245
- copyFile(
246
- srcDir,
247
- 'ai/skills/om-module-scaffold/references/navigation-patterns.md',
248
- join(targetDir, '.ai', 'skills', 'om-module-scaffold', 'references', 'navigation-patterns.md'),
249
- )
253
+ function ensureManagedParent(root: string, filePath: string): void {
254
+ const parent = dirname(filePath)
255
+ const normalizedRelative = relative(root, parent)
256
+ if (normalizedRelative === '..' || normalizedRelative.startsWith(`..${sep}`)) {
257
+ throw new Error(`Harness-managed path escapes its root: ${filePath}`)
258
+ }
259
+ let current = root
260
+ for (const component of normalizedRelative.split(sep).filter(Boolean)) {
261
+ assertManagedPath(root, current)
262
+ current = join(current, component)
263
+ const entry = lstatSync(current, { throwIfNoEntry: false })
264
+ if (!entry) mkdirSync(current)
265
+ const created = lstatSync(current, { throwIfNoEntry: false })
266
+ if (!created || created.isSymbolicLink() || !created.isDirectory()) {
267
+ throw new Error(`Harness-managed path component is not a real directory: ${current}`)
268
+ }
269
+ }
270
+ }
250
271
 
251
- copyFile(
252
- srcDir,
253
- 'ai/skills/om-troubleshooter/SKILL.md',
254
- join(targetDir, '.ai', 'skills', 'om-troubleshooter', 'SKILL.md'),
255
- )
256
- copyFile(
257
- srcDir,
258
- 'ai/skills/om-troubleshooter/references/diagnostic-commands.md',
259
- join(targetDir, '.ai', 'skills', 'om-troubleshooter', 'references', 'diagnostic-commands.md'),
260
- )
272
+ function harnessGeneratorId(): string {
273
+ try {
274
+ const upstream = JSON.parse(readFileSync(join(GUIDES_DIR, 'upstream', 'manifest.json'), 'utf8')) as {
275
+ generator?: unknown
276
+ }
277
+ const generator = typeof upstream.generator === 'string' ? upstream.generator : ''
278
+ const version = generator.slice(generator.lastIndexOf('@') + 1)
279
+ if (version && version !== generator) return `open-mercato-agentic@${version}`
280
+ } catch {
281
+ // Source-mode tests may not have built upstream snapshots yet.
282
+ }
283
+ return 'open-mercato-agentic@unknown'
284
+ }
261
285
 
262
- copyFile(
263
- srcDir,
264
- 'ai/skills/om-eject-and-customize/SKILL.md',
265
- join(targetDir, '.ai', 'skills', 'om-eject-and-customize', 'SKILL.md'),
266
- )
286
+ function externalSkillNames(targetDir: string): Set<string> {
287
+ try {
288
+ const tiers = JSON.parse(readFileSync(join(targetDir, '.ai', 'skills', 'tiers.json'), 'utf8')) as {
289
+ external?: { skills?: unknown }
290
+ }
291
+ return new Set(Array.isArray(tiers.external?.skills) ? tiers.external.skills.filter((name): name is string => typeof name === 'string') : [])
292
+ } catch {
293
+ return new Set()
294
+ }
295
+ }
267
296
 
268
- copyFile(
269
- srcDir,
270
- 'ai/skills/om-data-model-design/SKILL.md',
271
- join(targetDir, '.ai', 'skills', 'om-data-model-design', 'SKILL.md'),
272
- )
273
- copyFile(
274
- srcDir,
275
- 'ai/skills/om-data-model-design/references/mikro-orm-cheatsheet.md',
276
- join(targetDir, '.ai', 'skills', 'om-data-model-design', 'references', 'mikro-orm-cheatsheet.md'),
277
- )
297
+ function atomicCopyFile(sourcePath: string, destinationPath: string, managedRoot?: string): void {
298
+ if (managedRoot) {
299
+ assertManagedPath(managedRoot, destinationPath, { leaf: 'file' })
300
+ ensureManagedParent(managedRoot, destinationPath)
301
+ } else {
302
+ ensureDir(destinationPath)
303
+ }
304
+ const temporaryPath = `${destinationPath}.tmp-${process.pid}-${Date.now()}`
305
+ try {
306
+ if (managedRoot) assertManagedPath(managedRoot, temporaryPath, { leaf: 'file' })
307
+ copyFileSync(sourcePath, temporaryPath)
308
+ if (process.platform !== 'win32') chmodSync(temporaryPath, statSync(sourcePath).mode & 0o777)
309
+ if (managedRoot) {
310
+ assertManagedPath(managedRoot, dirname(destinationPath))
311
+ assertManagedPath(managedRoot, destinationPath, { leaf: 'file' })
312
+ assertManagedPath(managedRoot, temporaryPath, { leaf: 'file' })
313
+ }
314
+ renameSync(temporaryPath, destinationPath)
315
+ } finally {
316
+ if (!managedRoot || !lstatSync(temporaryPath, { throwIfNoEntry: false })?.isSymbolicLink()) {
317
+ rmSync(temporaryPath, { force: true })
318
+ }
319
+ }
320
+ }
278
321
 
279
- copyFile(
280
- srcDir,
281
- 'ai/skills/om-implement-spec/SKILL.md',
282
- join(targetDir, '.ai', 'skills', 'om-implement-spec', 'SKILL.md'),
283
- )
322
+ function atomicWriteFile(destinationPath: string, content: string, managedRoot?: string): void {
323
+ if (managedRoot) {
324
+ assertManagedPath(managedRoot, destinationPath, { leaf: 'file' })
325
+ ensureManagedParent(managedRoot, destinationPath)
326
+ } else {
327
+ ensureDir(destinationPath)
328
+ }
329
+ const temporaryPath = `${destinationPath}.tmp-${process.pid}-${Date.now()}`
330
+ try {
331
+ if (managedRoot) assertManagedPath(managedRoot, temporaryPath, { leaf: 'file' })
332
+ writeFileSync(temporaryPath, content)
333
+ if (managedRoot) {
334
+ assertManagedPath(managedRoot, dirname(destinationPath))
335
+ assertManagedPath(managedRoot, destinationPath, { leaf: 'file' })
336
+ assertManagedPath(managedRoot, temporaryPath, { leaf: 'file' })
337
+ }
338
+ renameSync(temporaryPath, destinationPath)
339
+ } finally {
340
+ if (!managedRoot || !lstatSync(temporaryPath, { throwIfNoEntry: false })?.isSymbolicLink()) {
341
+ rmSync(temporaryPath, { force: true })
342
+ }
343
+ }
344
+ }
284
345
 
285
- copyFile(
286
- srcDir,
287
- 'ai/skills/om-help/SKILL.md',
288
- join(targetDir, '.ai', 'skills', 'om-help', 'SKILL.md'),
289
- )
290
- copyFile(
291
- srcDir,
292
- 'ai/skills/om-help/references/skills-catalog.md',
293
- join(targetDir, '.ai', 'skills', 'om-help', 'references', 'skills-catalog.md'),
294
- )
295
- copyFile(
296
- srcDir,
297
- 'ai/skills/om-help/references/workflow-sequences.md',
298
- join(targetDir, '.ai', 'skills', 'om-help', 'references', 'workflow-sequences.md'),
299
- )
346
+ function resolveManifestPath(root: string, relativePath: string): string | null {
347
+ if (!relativePath || relativePath.includes('\0')) return null
348
+ const absolutePath = resolve(root, relativePath)
349
+ const normalizedRelative = relative(root, absolutePath)
350
+ if (
351
+ normalizedRelative === '' ||
352
+ normalizedRelative === '..' ||
353
+ normalizedRelative.startsWith(`..${sep}`)
354
+ ) {
355
+ return null
356
+ }
357
+ return absolutePath
358
+ }
359
+
360
+ function normalizeManifestRelativePath(path: string): string {
361
+ return path.replace(/\\/g, '/')
362
+ }
363
+
364
+ function readHarnessManifest(manifestPath: string): HarnessManifest | null {
365
+ if (!existsSync(manifestPath)) return null
366
+ try {
367
+ const parsed = JSON.parse(readFileSync(manifestPath, 'utf8')) as Partial<HarnessManifest>
368
+ if (
369
+ typeof parsed.version !== 'number' ||
370
+ typeof parsed.generator !== 'string' ||
371
+ !Array.isArray(parsed.files)
372
+ ) {
373
+ return null
374
+ }
375
+ const files: HarnessManifestFile[] = []
376
+ for (const entry of parsed.files) {
377
+ if (
378
+ !entry ||
379
+ typeof entry.path !== 'string' ||
380
+ typeof entry.sha256 !== 'string' ||
381
+ !/^[a-f0-9]{64}$/.test(entry.sha256) ||
382
+ typeof entry.source !== 'string' ||
383
+ typeof entry.userEditable !== 'boolean'
384
+ ) {
385
+ return null
386
+ }
387
+ files.push({ ...entry, path: normalizeManifestRelativePath(entry.path) })
388
+ }
389
+ return { version: parsed.version, generator: parsed.generator, files }
390
+ } catch {
391
+ return null
392
+ }
393
+ }
394
+
395
+ function targetPathsForTree(sourceRoot: string, destinationRoot: string): string[] {
396
+ return listFiles(sourceRoot).map((sourcePath) => join(destinationRoot, relative(sourceRoot, sourcePath)))
397
+ }
300
398
 
301
- copyFile(
302
- srcDir,
303
- 'ai/skills/om-auto-upgrade-0.4.10-to-0.5.0/SKILL.md',
304
- join(targetDir, '.ai', 'skills', 'om-auto-upgrade-0.4.10-to-0.5.0', 'SKILL.md'),
399
+ function finalizeHarnessManifest(config: AgenticConfig, selectedTools: string[]): void {
400
+ const { targetDir } = config
401
+ const srcDir = join(AGENTIC_DIR, 'shared')
402
+ const selectedModules = selectModuleFactSheets(targetDir, join(GUIDES_DIR, 'modules'))
403
+ const paths = new Set<string>([
404
+ join(targetDir, 'AGENTS.md'),
405
+ ...targetPathsForTree(join(srcDir, 'ai'), join(targetDir, '.ai')),
406
+ ...targetPathsForTree(join(srcDir, 'scripts'), join(targetDir, 'scripts')),
407
+ ])
408
+ for (const file of readdirSync(GUIDES_DIR)) {
409
+ if (file.endsWith('.md') || file === 'module-facts.json') paths.add(join(targetDir, '.ai', 'guides', file))
410
+ }
411
+ for (const file of listFiles(join(GUIDES_DIR, 'upstream'))) {
412
+ paths.add(join(targetDir, '.ai', 'guides', 'upstream', relative(join(GUIDES_DIR, 'upstream'), file)))
413
+ }
414
+ for (const moduleId of selectedModules) paths.add(join(targetDir, '.ai', 'guides', 'modules', `${moduleId}.md`))
415
+ if (selectedTools.includes('claude-code')) {
416
+ paths.add(join(targetDir, 'CLAUDE.md'))
417
+ paths.add(join(targetDir, '.claude', 'settings.json'))
418
+ paths.add(join(targetDir, '.claude', 'hooks', 'entity-migration-check.ts'))
419
+ paths.add(join(targetDir, '.mcp.json.example'))
420
+ }
421
+ if (selectedTools.includes('codex')) paths.add(join(targetDir, '.codex', 'mcp.json.example'))
422
+ if (selectedTools.includes('cursor')) {
423
+ for (const file of listFiles(join(AGENTIC_DIR, 'cursor'))) {
424
+ const rel = relative(join(AGENTIC_DIR, 'cursor'), file)
425
+ paths.add(join(targetDir, '.cursor', rel))
426
+ }
427
+ }
428
+ const manifestPath = join(targetDir, '.ai', 'harness', 'manifest.json')
429
+ const externalSkills = externalSkillNames(targetDir)
430
+ paths.delete(manifestPath)
431
+ const files = [...paths]
432
+ .filter((path) => existsSync(path))
433
+ .sort((left, right) => left.localeCompare(right))
434
+ .map((path) => {
435
+ const relativePath = normalizeManifestRelativePath(relative(targetDir, path))
436
+ const skillName = relativePath.match(/^\.ai\/skills\/([^/]+)\//)?.[1]
437
+ return {
438
+ path: relativePath,
439
+ sha256: hashFile(path),
440
+ source: skillName ? (externalSkills.has(skillName) ? 'external-override' : 'local-skill') : 'generated',
441
+ userEditable: relativePath === 'AGENTS.md' || relativePath === '.ai/agentic.config.json' || relativePath === '.ai/lessons.md',
442
+ }
443
+ })
444
+ atomicWriteFile(
445
+ manifestPath,
446
+ `${JSON.stringify({ version: 1, generator: harnessGeneratorId(), files }, null, 2)}\n`,
305
447
  )
448
+ }
449
+
450
+ /**
451
+ * Publish a fully generated harness candidate using the previous ownership
452
+ * manifest as the only source of ownership. Modified owned files and exact-path
453
+ * collisions with unknown files stay untouched; their candidate is written next
454
+ * to them as `<path>.incoming` for explicit review.
455
+ */
456
+ export type HarnessUpdateConflict = { path: string; candidate: string | null }
457
+
458
+ function preserveIncomingCandidate(
459
+ targetRoot: string,
460
+ destinationPath: string,
461
+ sourcePath: string,
462
+ relativePath: string,
463
+ ): string {
464
+ for (let suffix = 1; ; suffix += 1) {
465
+ const label = suffix === 1 ? '.incoming' : `.incoming.${suffix}`
466
+ const candidatePath = `${destinationPath}${label}`
467
+ assertManagedPath(targetRoot, candidatePath, { leaf: 'file' })
468
+ if (!existsSync(candidatePath)) {
469
+ atomicCopyFile(sourcePath, candidatePath, targetRoot)
470
+ return `${relativePath}${label}`
471
+ }
472
+ if (hashFile(candidatePath) === hashFile(sourcePath)) return `${relativePath}${label}`
473
+ }
474
+ }
306
475
 
307
- // Slim repo-local OVERRIDE folders (SKILL.md only) for the external auto-* PR
308
- // family + single autofix skill. The workflow bodies live in open-mercato/skills
309
- // (installed via `yarn install-skills`); these overrides adjust them for a
310
- // standalone app and are read on top of the external skill's built-in workflow.
311
- // om-prepare-test-env's override carries environment knowledge (cross-platform
312
- // mercato CLI runner commands) rather than tracker adjustments.
313
- for (const autoSkill of [
314
- 'om-auto-create-pr',
315
- 'om-auto-continue-pr',
316
- 'om-auto-create-pr-loop',
317
- 'om-auto-continue-pr-loop',
318
- 'om-auto-review-pr',
319
- 'om-auto-fix-issue',
320
- 'om-prepare-test-env',
321
- ]) {
322
- if (!existsSync(join(srcDir, 'ai', 'skills', autoSkill, 'SKILL.md'))) {
476
+ export function applyHarnessUpdate(
477
+ targetDir: string,
478
+ stagingDir: string,
479
+ options: { force?: boolean } = {},
480
+ ): HarnessUpdateConflict[] {
481
+ const targetRoot = canonicalHarnessRoot(targetDir)
482
+ const stagingRoot = canonicalHarnessRoot(stagingDir)
483
+ const targetManifestPath = join(targetRoot, '.ai', 'harness', 'manifest.json')
484
+ const stagingManifestPath = join(stagingRoot, '.ai', 'harness', 'manifest.json')
485
+ assertManagedPath(targetRoot, targetManifestPath, { leaf: 'file' })
486
+ assertManagedPath(stagingRoot, stagingManifestPath, { leaf: 'file' })
487
+ const candidateManifest = readHarnessManifest(stagingManifestPath)
488
+ if (!candidateManifest) {
489
+ throw new Error('Generated harness candidate has a missing or invalid ownership manifest.')
490
+ }
491
+
492
+ const previousManifest = readHarnessManifest(targetManifestPath)
493
+ const previousFiles = new Map(previousManifest?.files.map((entry) => [entry.path, entry]) ?? [])
494
+ const candidatePaths = new Set(candidateManifest.files.map((entry) => entry.path))
495
+ const candidates: Array<{
496
+ entry: HarnessManifestFile
497
+ sourcePath: string
498
+ destinationPath: string
499
+ }> = []
500
+
501
+ // Validate the complete candidate before writing anything into the app.
502
+ for (const entry of candidateManifest.files) {
503
+ const sourcePath = resolveManifestPath(stagingRoot, entry.path)
504
+ const destinationPath = resolveManifestPath(targetRoot, entry.path)
505
+ if (sourcePath) assertManagedPath(stagingRoot, sourcePath, { leaf: 'file' })
506
+ if (destinationPath) assertManagedPath(targetRoot, destinationPath, { leaf: 'file' })
507
+ if (!sourcePath || !destinationPath || !existsSync(sourcePath) || hashFile(sourcePath) !== entry.sha256) {
508
+ throw new Error(`Generated harness candidate is invalid for ${JSON.stringify(entry.path)}.`)
509
+ }
510
+ candidates.push({ entry, sourcePath, destinationPath })
511
+ }
512
+
513
+ const conflicts: HarnessUpdateConflict[] = []
514
+ for (const { entry, sourcePath, destinationPath } of candidates) {
515
+ if (options.force) {
516
+ atomicCopyFile(sourcePath, destinationPath, targetRoot)
323
517
  continue
324
518
  }
325
- copyFile(
326
- srcDir,
327
- `ai/skills/${autoSkill}/SKILL.md`,
328
- join(targetDir, '.ai', 'skills', autoSkill, 'SKILL.md'),
329
- )
519
+ if (!existsSync(destinationPath)) {
520
+ atomicCopyFile(sourcePath, destinationPath, targetRoot)
521
+ continue
522
+ }
523
+
524
+ const currentHash = hashFile(destinationPath)
525
+ const previousEntry = previousFiles.get(entry.path)
526
+ if (currentHash === entry.sha256 || (previousEntry && currentHash === previousEntry.sha256)) {
527
+ atomicCopyFile(sourcePath, destinationPath, targetRoot)
528
+ continue
529
+ }
530
+
531
+ const candidate = preserveIncomingCandidate(targetRoot, destinationPath, sourcePath, entry.path)
532
+ conflicts.push({ path: entry.path, candidate })
330
533
  }
331
534
 
332
- copyFile(
333
- srcDir,
334
- 'ai/skills/om-trim-unused-modules/SKILL.md',
335
- join(targetDir, '.ai', 'skills', 'om-trim-unused-modules', 'SKILL.md'),
336
- )
535
+ const retainedRetiredEntries: HarnessManifestFile[] = []
536
+ for (const previousEntry of previousManifest?.files ?? []) {
537
+ if (candidatePaths.has(previousEntry.path)) continue
538
+ const destinationPath = resolveManifestPath(targetRoot, previousEntry.path)
539
+ if (!destinationPath || !existsSync(destinationPath)) continue
540
+ assertManagedPath(targetRoot, destinationPath, { leaf: 'file' })
541
+ let unchanged = false
542
+ try {
543
+ unchanged = hashFile(destinationPath) === previousEntry.sha256
544
+ } catch {
545
+ // A user may replace a generated file with another filesystem object.
546
+ // Preserve it and retain the ownership tombstone for a later review.
547
+ }
548
+ if (unchanged) {
549
+ assertManagedPath(targetRoot, dirname(destinationPath))
550
+ assertManagedPath(targetRoot, destinationPath, { leaf: 'file' })
551
+ rmSync(destinationPath, { force: true })
552
+ } else {
553
+ retainedRetiredEntries.push(previousEntry)
554
+ conflicts.push({ path: previousEntry.path, candidate: null })
555
+ }
556
+ }
337
557
 
338
- copyFile(srcDir, 'ai/qa/tests/playwright.config.ts', join(targetDir, '.ai', 'qa', 'tests', 'playwright.config.ts'))
558
+ // The candidate hashes deliberately remain in the manifest for conflicts. If
559
+ // the user accepts an .incoming file, a later update can recognize it as an
560
+ // unmodified owned file. Until then the hash mismatch keeps preserving it.
561
+ const finalManifest: HarnessManifest = {
562
+ ...candidateManifest,
563
+ files: [...candidateManifest.files, ...retainedRetiredEntries],
564
+ }
565
+ atomicWriteFile(targetManifestPath, `${JSON.stringify(finalManifest, null, 2)}\n`, targetRoot)
566
+ return conflicts
567
+ }
568
+
569
+ // ─── Generators ──────────────────────────────────────────────────────────
339
570
 
340
- // Package & conceptual guides are copied wholesale (framework-wide). Per-module
571
+ function generateShared(config: AgenticConfig): void {
572
+ const { targetDir } = config
573
+ const srcDir = join(AGENTIC_DIR, 'shared')
574
+
575
+ // Resolve which per-module fact-sheets this app gets (enabled ∩ bundled allowlist).
576
+ const selectedModules = selectModuleFactSheets(targetDir, join(GUIDES_DIR, 'modules'))
577
+
578
+ // One recursive mapping mirrors create-app's shared emitter.
579
+ writeTemplate(srcDir, 'AGENTS.md.template', join(targetDir, 'AGENTS.md'), config)
580
+ copyTree(join(srcDir, 'ai'), join(targetDir, '.ai'), config)
581
+ copyTree(join(srcDir, 'scripts'), join(targetDir, 'scripts'), config)
582
+
583
+ // Routed conceptual guides are copied wholesale (framework-wide). Per-module
341
584
  // fact-sheets (.ai/guides/modules/<module>.md) are filtered to the app's enabled
342
585
  // module set; the combined module-facts.json sidecar is copied as-is.
343
586
  if (existsSync(GUIDES_DIR)) {
@@ -357,6 +600,9 @@ function generateShared(config: AgenticConfig): void {
357
600
  copyFileSync(moduleFactsPath, destPath)
358
601
  }
359
602
 
603
+
604
+ copyTree(join(GUIDES_DIR, 'upstream'), join(guidesDestDir, 'upstream'), config)
605
+
360
606
  const modulesSubdir = join(GUIDES_DIR, 'modules')
361
607
  for (const moduleId of selectedModules) {
362
608
  const destPath = join(guidesDestDir, 'modules', `${moduleId}.md`)
@@ -364,6 +610,8 @@ function generateShared(config: AgenticConfig): void {
364
610
  copyFileSync(join(modulesSubdir, `${moduleId}.md`), destPath)
365
611
  }
366
612
  }
613
+
614
+ injectModuleGuides(join(targetDir, 'AGENTS.md'), selectedModules)
367
615
  }
368
616
 
369
617
  function generateClaudeCode(config: AgenticConfig): void {
@@ -375,8 +623,7 @@ function generateClaudeCode(config: AgenticConfig): void {
375
623
  copyFile(srcDir, 'hooks/entity-migration-check.ts', join(targetDir, '.claude', 'hooks', 'entity-migration-check.ts'))
376
624
  copyFile(srcDir, 'mcp.json.example', join(targetDir, '.mcp.json.example'))
377
625
 
378
- // Symlink .claude/skills → ../.ai/skills
379
- ensureSkillsLink(join(targetDir, '.claude', 'skills'), join('..', '.ai', 'skills'))
626
+ // The installer exclusively owns Claude's per-skill compatibility links.
380
627
  }
381
628
 
382
629
  function generateCodex(config: AgenticConfig): void {
@@ -428,17 +675,6 @@ function generateCursor(config: AgenticConfig): void {
428
675
  // which scripts/install-skills.sh populates.
429
676
  }
430
677
 
431
- function ensureSkillsLink(linkPath: string, target: string): void {
432
- ensureDir(linkPath)
433
- if (existsSync(linkPath) && !lstatSync(linkPath).isSymbolicLink()) {
434
- return
435
- }
436
- if (lstatSync(linkPath, { throwIfNoEntry: false })?.isSymbolicLink()) {
437
- unlinkSync(linkPath)
438
- }
439
- symlinkSync(target, linkPath)
440
- }
441
-
442
678
  // ─── Wizard ──────────────────────────────────────────────────────────────
443
679
 
444
680
  const TOOLS = [
@@ -451,6 +687,35 @@ const TOOLS = [
451
687
 
452
688
  const SELECTABLE = TOOLS.filter((t) => t.id !== 'multiple' && t.id !== 'skip')
453
689
 
690
+ function persistedAgentSelection(targetDir: string): string[] | null {
691
+ const selectableIds = SELECTABLE.map((tool) => tool.id)
692
+ const tiersPath = join(targetDir, '.ai', 'skills', 'tiers.json')
693
+ try {
694
+ const tiers = JSON.parse(readFileSync(tiersPath, 'utf8')) as { agents?: { ignore?: unknown } }
695
+ if (Array.isArray(tiers.agents?.ignore)
696
+ && tiers.agents.ignore.every((id): id is string => typeof id === 'string')) {
697
+ const ignored = new Set(tiers.agents.ignore)
698
+ return selectableIds.filter((id) => !ignored.has(id))
699
+ }
700
+ } catch {
701
+ // Fall through to the ownership manifest and existing generated assets.
702
+ }
703
+
704
+ const manifest = readHarnessManifest(join(targetDir, '.ai', 'harness', 'manifest.json'))
705
+ const owned = new Set(manifest?.files.map((entry) => entry.path) ?? [])
706
+ const selected = SELECTABLE.filter((tool) => {
707
+ if (tool.id === 'claude-code') {
708
+ return owned.has('CLAUDE.md') || owned.has('.claude/settings.json')
709
+ || existsSync(join(targetDir, 'CLAUDE.md')) || existsSync(join(targetDir, '.claude', 'settings.json'))
710
+ }
711
+ if (tool.id === 'codex') {
712
+ return owned.has('.codex/mcp.json.example') || existsSync(join(targetDir, '.codex', 'mcp.json.example'))
713
+ }
714
+ return owned.has('.cursor/hooks.json') || existsSync(join(targetDir, '.cursor', 'hooks.json'))
715
+ }).map((tool) => tool.id)
716
+ return selected.length > 0 ? selected : null
717
+ }
718
+
454
719
  async function promptSelection(ask: AskFn): Promise<string[]> {
455
720
  console.log('')
456
721
  console.log('🤖 Agentic workflow setup')
@@ -497,6 +762,8 @@ export async function runAgenticSetup(
497
762
 
498
763
  if (options?.tool) {
499
764
  selectedIds = options.tool.split(',').map((t) => t.trim())
765
+ } else if (options?.updateHarness) {
766
+ selectedIds = persistedAgentSelection(targetDir) ?? await promptSelection(ask)
500
767
  } else {
501
768
  selectedIds = await promptSelection(ask)
502
769
  }
@@ -513,12 +780,37 @@ export async function runAgenticSetup(
513
780
  targetDir,
514
781
  }
515
782
 
516
- generateShared(config)
517
- if (selectedIds.includes('claude-code')) generateClaudeCode(config)
518
- if (selectedIds.includes('codex')) generateCodex(config)
519
- if (selectedIds.includes('cursor')) generateCursor(config)
783
+ const stagingDir = mkdtempSync(join(tmpdir(), 'open-mercato-harness-'))
784
+ try {
785
+ const modulesSourcePath = join(targetDir, 'src', 'modules.ts')
786
+ const modulesCandidatePath = join(stagingDir, 'src', 'modules.ts')
787
+ if (existsSync(modulesSourcePath)) {
788
+ ensureDir(modulesCandidatePath)
789
+ copyFileSync(modulesSourcePath, modulesCandidatePath)
790
+ }
791
+
792
+ const stagingConfig: AgenticConfig = {
793
+ projectName: config.projectName,
794
+ targetDir: stagingDir,
795
+ }
796
+ generateHarness(stagingConfig, selectedIds)
797
+ const conflicts = applyHarnessUpdate(targetDir, stagingDir, {
798
+ force: options?.force,
799
+ })
800
+ if (conflicts.length > 0) {
801
+ console.warn('')
802
+ console.warn(' ⚠ Preserved locally modified harness files:')
803
+ for (const conflict of conflicts) {
804
+ const detail = conflict.candidate
805
+ ? `candidate: ${conflict.candidate}`
806
+ : 'retired asset kept because it has local changes'
807
+ console.warn(` • ${conflict.path} (${detail})`)
808
+ }
809
+ }
810
+ } finally {
811
+ rmSync(stagingDir, { recursive: true, force: true })
812
+ }
520
813
 
521
- persistAgentSelection(targetDir, selectedIds)
522
814
  installSkills(targetDir)
523
815
 
524
816
  console.log('')
@@ -540,6 +832,16 @@ export async function runAgenticSetup(
540
832
  console.log('')
541
833
  }
542
834
 
835
+ function generateHarness(config: AgenticConfig, selectedIds: string[]): void {
836
+ generateShared(config)
837
+ if (selectedIds.includes('claude-code')) generateClaudeCode(config)
838
+ if (selectedIds.includes('codex')) generateCodex(config)
839
+ if (selectedIds.includes('cursor')) generateCursor(config)
840
+
841
+ persistAgentSelection(config.targetDir, selectedIds)
842
+ finalizeHarnessManifest(config, selectedIds)
843
+ }
844
+
543
845
  /**
544
846
  * Persist the agent selection so later `yarn install-skills` runs keep honoring
545
847
  * it: agents the user did not pick go into `agents.ignore` in tiers.json and
@@ -559,11 +861,11 @@ function persistAgentSelection(targetDir: string, selectedIds: string[]): void {
559
861
  }
560
862
 
561
863
  function installSkills(targetDir: string): void {
562
- const installScript = join(targetDir, 'scripts', 'install-skills.sh')
864
+ const installScript = join(targetDir, 'scripts', 'install-skills.mjs')
563
865
  if (!existsSync(installScript)) return
564
866
  console.log('')
565
867
  console.log(' Installing agent skills (local tiers + external open-mercato/skills subset)...')
566
- const result = spawnSync('sh', [installScript], { cwd: targetDir, stdio: 'inherit' })
868
+ const result = spawnSync(process.execPath, [installScript], { cwd: targetDir, stdio: 'inherit' })
567
869
  if (result.error || result.status !== 0) {
568
870
  console.log(' ⚠ Skill installation did not complete; run `yarn install-skills` inside the app when online.')
569
871
  }